Skip to content

Commit 368cefe

Browse files
InfantLabclaude
andcommitted
feat(ourmoji): admin daily ingest endpoint for home-server agent
Adds POST /api/v1/admin/ourmoji/daily — an admin-scoped endpoint that lets a trusted external agent post the daily Ourmoji oracle on behalf of any user, authenticated with a single Bearer API key (admin:users:write). Widens the key-mint schema to accept admin permissions, gated by isAdmin() so only admins can grant them. Ships a walkthrough at docs/modules/ourmoji-agent-setup.md covering key minting, per-user module enablement, daily POST shape, and key rotation — all with full production URLs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 07ef91e commit 368cefe

5 files changed

Lines changed: 352 additions & 1 deletion

File tree

app/server/api/ourmoji/schemas.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ export const dailyOurmojiPayloadSchema = z.object({
3535

3636
export type DailyOurmojiPayload = z.infer<typeof dailyOurmojiPayloadSchema>;
3737

38+
/**
39+
* Admin variant — same fields as `dailyOurmojiPayloadSchema` plus a
40+
* target `userId` so a privileged caller can post on behalf of any
41+
* user. Used by `POST /api/v1/admin/ourmoji/daily`.
42+
*/
43+
export const adminDailyOurmojiPayloadSchema =
44+
dailyOurmojiPayloadSchema.extend({
45+
userId: z.string().min(1),
46+
});
47+
48+
export type AdminDailyOurmojiPayload = z.infer<
49+
typeof adminDailyOurmojiPayloadSchema
50+
>;
51+
3852
export const calendarQuerySchema = z.object({
3953
from: isoDate.optional(),
4054
to: isoDate.optional(),
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* POST /api/v1/admin/ourmoji/daily
3+
*
4+
* Admin ingestion endpoint — posts a single day's Ourmoji payload on
5+
* behalf of a target user. Intended for trusted server-to-server agents
6+
* (e.g. the home-server oracle) authenticated with an admin API key.
7+
*
8+
* Auth: requires admin + `admin:users:write` permission (API keys).
9+
* Sessions held by an admin also work.
10+
*
11+
* Idempotent per (userId, date): a re-post for the same date updates
12+
* the existing entry rather than duplicating.
13+
*/
14+
15+
import { defineEventHandler, readBody, createError } from "h3";
16+
17+
import { requireAdmin } from "~/server/utils/admin";
18+
import {
19+
notFound,
20+
success,
21+
validationError,
22+
} from "~/server/utils/response";
23+
import { logAuthEvent } from "~/server/utils/authEvents";
24+
import { db } from "~/server/db";
25+
import { users } from "~/server/db/schema";
26+
import { eq } from "drizzle-orm";
27+
28+
import { isOurmojiEnabledForUser } from "~/server/services/ourmoji/access";
29+
import { upsertDailyOurmoji } from "~/server/services/ourmoji/daily";
30+
import { ourmojiApiLogger as logger } from "~/server/services/ourmoji/logger";
31+
import { adminDailyOurmojiPayloadSchema } from "~/server/api/ourmoji/schemas";
32+
33+
export default defineEventHandler(async (event) => {
34+
requireAdmin(event, "admin:users:write");
35+
36+
const body = await readBody(event);
37+
const parsed = adminDailyOurmojiPayloadSchema.safeParse(body);
38+
39+
if (!parsed.success) {
40+
const errors: Record<string, string[]> = {};
41+
for (const issue of parsed.error.issues) {
42+
const path = issue.path.join(".") || "_root";
43+
if (!errors[path]) errors[path] = [];
44+
errors[path].push(issue.message);
45+
}
46+
throw createError(validationError(event, errors));
47+
}
48+
49+
const payload = parsed.data;
50+
51+
const targetUser = await db
52+
.select({ id: users.id })
53+
.from(users)
54+
.where(eq(users.id, payload.userId))
55+
.limit(1);
56+
57+
if (targetUser.length === 0) {
58+
throw createError(notFound(event, "User"));
59+
}
60+
61+
const enabled = await isOurmojiEnabledForUser(payload.userId);
62+
if (!enabled) {
63+
throw createError({
64+
statusCode: 400,
65+
statusMessage: "Target user does not have the ourmoji module enabled",
66+
});
67+
}
68+
69+
const dto = await upsertDailyOurmoji({
70+
userId: payload.userId,
71+
date: payload.date,
72+
emoji: payload.emoji,
73+
reflection: payload.reflection,
74+
moonPhase: payload.moonPhase,
75+
moonIllumination: payload.moonIllumination ?? null,
76+
wheelOfYear: payload.wheelOfYear ?? null,
77+
wheelCategory: payload.wheelCategory ?? null,
78+
timezone: payload.timezone,
79+
source: "api",
80+
});
81+
82+
const auth = event.context.auth!;
83+
await logAuthEvent({
84+
event,
85+
userId: auth.userId,
86+
eventType: "admin:user_updated",
87+
metadata: {
88+
targetUserId: payload.userId,
89+
action: "ourmoji_daily_ingested",
90+
date: payload.date,
91+
},
92+
});
93+
94+
logger.info("Admin ingested Ourmoji daily", {
95+
adminUserId: auth.userId,
96+
targetUserId: payload.userId,
97+
date: payload.date,
98+
});
99+
100+
return success(event, { entry: dto });
101+
});

app/server/api/v1/auth/keys.post.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,23 @@
77
*/
88

99
import * as z from "zod";
10-
import { created, apiError, validationError } from "~/server/utils/response";
10+
import { created, apiError, validationError, forbidden } from "~/server/utils/response";
1111
import { createApiKey } from "~/server/utils/api-key";
1212
import { createLogger } from "~/server/utils/logger";
13+
import { isAdmin } from "~/server/utils/admin";
1314
import type { Permission } from "~/types/api";
1415

1516
const logger = createLogger("api:v1:auth:keys:create");
1617

18+
const ADMIN_PERMISSIONS = [
19+
"admin:stats",
20+
"admin:users",
21+
"admin:users:write",
22+
"admin:activity",
23+
"admin:health",
24+
"admin:feedback",
25+
] as const satisfies readonly Permission[];
26+
1727
// Validation schema for API key creation
1828
const createKeySchema = z.object({
1929
name: z.string().min(1).max(100),
@@ -26,7 +36,9 @@ const createKeySchema = z.object({
2636
"insights:read",
2737
"export:read",
2838
"webhooks:manage",
39+
"sync:manage",
2940
"user:read",
41+
...ADMIN_PERMISSIONS,
3042
]),
3143
)
3244
.min(1)
@@ -71,6 +83,15 @@ export default defineEventHandler(async (event) => {
7183

7284
const { name, permissions, expiresAt } = parseResult.data;
7385

86+
const requestsAdminPerm = permissions.some((p) =>
87+
(ADMIN_PERMISSIONS as readonly string[]).includes(p),
88+
);
89+
if (requestsAdminPerm && !isAdmin(userId)) {
90+
throw createError(
91+
forbidden(event, "Admin permissions can only be granted to admin users"),
92+
);
93+
}
94+
7495
try {
7596
// Create new API key
7697
const result = await createApiKey(
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# Ourmoji — Home-Server Agent Setup
2+
3+
How to configure an external agent (running on your home server) to post the
4+
daily Ourmoji oracle on behalf of multiple users.
5+
6+
The agent authenticates as the project owner using a single long-lived API key
7+
with admin scope, and posts to an admin-only endpoint that accepts a target
8+
`userId`. No per-user session cookies or per-user keys are needed.
9+
10+
Production base URL: `https://tada.living`
11+
12+
Replace `https://tada.living` below if running against a different deployment
13+
(e.g. `http://localhost:3000` for local dev).
14+
15+
---
16+
17+
## Prerequisites
18+
19+
1. **You are an admin.** Your user id must be listed in the server-side
20+
`ADMIN_USER_IDS` env var (comma-separated).
21+
2. **You know both user ids** — Caspar's (yours) and Marian's.
22+
You can find user ids via `GET https://tada.living/api/v1/admin/users` once
23+
you have a session, or in the database directly.
24+
3. **You are logged in to tada in a browser** on the same origin (for the key
25+
minting step, which requires session auth).
26+
27+
---
28+
29+
## Step 1 — Mint the API key (browser)
30+
31+
Open the devtools console on any `https://tada.living/*` page while signed in,
32+
and run:
33+
34+
```js
35+
await fetch("https://tada.living/api/v1/auth/keys", {
36+
method: "POST",
37+
credentials: "include",
38+
headers: { "Content-Type": "application/json" },
39+
body: JSON.stringify({
40+
name: "home-server ourmoji agent",
41+
permissions: ["admin:users:write"],
42+
}),
43+
}).then((r) => r.json());
44+
```
45+
46+
The response includes a `key` field of the form `tada_key_…`. **This is the
47+
only time the plaintext key is ever shown.** Copy it to the home-server agent's
48+
secret store immediately. If you lose it, revoke and mint a new one.
49+
50+
> `admin:users:write` is the same permission used by the existing admin
51+
> module-toggle and user-update endpoints. A single key with this scope can
52+
> post daily Ourmoji for any user, toggle feature flags, update users, reset
53+
> passwords, clear sessions, etc. Treat it like a root credential — do not
54+
> share it outside the home-server agent.
55+
56+
---
57+
58+
## Step 2 — Enable Ourmoji for each user (one-shot)
59+
60+
The agent can only post for users who have the `ourmoji` module flag turned
61+
on. Do this once per user from the home server (or anywhere with curl):
62+
63+
**For Caspar (you):**
64+
65+
```bash
66+
curl -X PATCH https://tada.living/api/v1/admin/users/<CASPAR_USER_ID>/modules \
67+
-H "Authorization: Bearer tada_key_…" \
68+
-H "Content-Type: application/json" \
69+
-d '{"ourmoji": true}'
70+
```
71+
72+
**For Marian:**
73+
74+
```bash
75+
curl -X PATCH https://tada.living/api/v1/admin/users/<MARIAN_USER_ID>/modules \
76+
-H "Authorization: Bearer tada_key_…" \
77+
-H "Content-Type: application/json" \
78+
-d '{"ourmoji": true}'
79+
```
80+
81+
A 200 with `{"data": {"enabledModules": {"ourmoji": true, …}}}` confirms the
82+
flag is set.
83+
84+
---
85+
86+
## Step 3 — Verify the key (optional)
87+
88+
Quick sanity check from the home server:
89+
90+
```bash
91+
curl https://tada.living/api/v1/admin/health \
92+
-H "Authorization: Bearer tada_key_…"
93+
```
94+
95+
Expect a 200. If you get 401, the key is wrong or revoked. If you get 403,
96+
your user is not in `ADMIN_USER_IDS` on the server.
97+
98+
---
99+
100+
## Step 4 — Daily posting (what the agent does each morning)
101+
102+
Once per user per day, POST to `/api/v1/admin/ourmoji/daily`:
103+
104+
```bash
105+
curl -X POST https://tada.living/api/v1/admin/ourmoji/daily \
106+
-H "Authorization: Bearer tada_key_…" \
107+
-H "Content-Type: application/json" \
108+
-d '{
109+
"userId": "<CASPAR_USER_ID>",
110+
"date": "2026-04-21",
111+
"emoji": "🌙",
112+
"reflection": "The waning gibbous whispers of release.",
113+
"moonPhase": "Waning Gibbous",
114+
"moonIllumination": 75,
115+
"wheelOfYear": "Beltane",
116+
"wheelCategory": "fire",
117+
"timezone": "Europe/London"
118+
}'
119+
```
120+
121+
### Field reference
122+
123+
| Field | Type | Required | Notes |
124+
|-------|------|----------|-------|
125+
| `userId` | string | yes | Target user (Caspar or Marian) |
126+
| `date` | string `YYYY-MM-DD` | yes | Local date the entry belongs to |
127+
| `emoji` | string (1–16 chars) | yes | Drawn from the 23-emoji Sacred Set |
128+
| `reflection` | string (≤ 5000 chars) | yes | Poetic reflection text |
129+
| `moonPhase` | string | yes | e.g. "Waning Gibbous" |
130+
| `moonIllumination` | number 0–100 | no | Percent illuminated |
131+
| `wheelOfYear` | string | no | e.g. "Beltane", "Samhain" |
132+
| `wheelCategory` | string | no | e.g. "fire", "water" |
133+
| `timezone` | string | yes | IANA zone, e.g. "Europe/London" |
134+
135+
### Idempotency
136+
137+
The endpoint is idempotent per `(userId, date)` — re-posting for the same date
138+
updates the existing entry rather than creating a duplicate. Safe to retry on
139+
network failure.
140+
141+
### Response
142+
143+
```json
144+
{
145+
"data": {
146+
"entry": {
147+
"id": "",
148+
"date": "2026-04-21",
149+
"emoji": "🌙",
150+
"reflection": "",
151+
"moonPhase": "Waning Gibbous",
152+
"moonIllumination": 75,
153+
"wheelOfYear": "Beltane",
154+
"wheelCategory": "fire",
155+
"timestamp": "2026-04-21T00:00:00Z",
156+
"timezone": "Europe/London"
157+
}
158+
}
159+
}
160+
```
161+
162+
### Error cases
163+
164+
| Status | Meaning | Fix |
165+
|--------|---------|-----|
166+
| 400 | "Target user does not have the ourmoji module enabled" | Run Step 2 for that user |
167+
| 401 | Missing/invalid Bearer token | Check the key |
168+
| 403 | Admin check failed | Ensure your user id is in `ADMIN_USER_IDS` |
169+
| 404 | Target user does not exist | Check the `userId` value |
170+
| 422 | Validation error | Check the payload shape against the table above |
171+
172+
---
173+
174+
## Rotating / revoking the key
175+
176+
List your keys (browser console, logged in):
177+
178+
```js
179+
await fetch("https://tada.living/api/v1/auth/keys", {
180+
credentials: "include",
181+
}).then((r) => r.json());
182+
```
183+
184+
Revoke by id:
185+
186+
```js
187+
await fetch("https://tada.living/api/v1/auth/keys/<KEY_ID>", {
188+
method: "DELETE",
189+
credentials: "include",
190+
});
191+
```
192+
193+
Then redo Step 1 to mint a replacement and swap it into the agent's secret
194+
store.
195+
196+
---
197+
198+
## What the agent needs to hold
199+
200+
- Base URL (`https://tada.living`)
201+
- One Bearer token (`tada_key_…`)
202+
- Caspar's user id
203+
- Marian's user id
204+
- Whatever it uses to compute each day's emoji / moon / Wheel of Year / reflection
205+
206+
That's it — no session cookies, no per-user credentials, no rotation
207+
co-ordination across users.
208+
209+
---
210+
211+
[Back to Ourmoji](./ourmoji.md)

docs/modules/ourmoji.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ Requires admin auth (`ADMIN_USER_IDS` env var) with `admin:users:write` permissi
7676

7777
Full design lives in [`specs/013-ourmoji-module/`](../../specs/013-ourmoji-module/) — see `spec.md`, `data-model.md`, and `contracts/openapi.yaml`.
7878

79+
## Related docs
80+
81+
- [Home-server agent setup](./ourmoji-agent-setup.md) — configuring an external agent to post the daily Ourmoji on behalf of multiple users
82+
7983
---
8084

8185
[Back to modules](./README.md)

0 commit comments

Comments
 (0)