dayhand 0.0.0-stage → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +100 -2
- package/backend/convex/_generated/api.d.ts +181 -0
- package/backend/convex/_generated/api.js +23 -0
- package/backend/convex/_generated/dataModel.d.ts +60 -0
- package/backend/convex/_generated/server.d.ts +162 -0
- package/backend/convex/_generated/server.js +101 -0
- package/backend/convex/apiKeys.ts +171 -0
- package/backend/convex/app.ts +197 -0
- package/backend/convex/auth.config.ts +28 -0
- package/backend/convex/calendar.ts +61 -0
- package/backend/convex/calendarFeeds.ts +105 -0
- package/backend/convex/callLimits.ts +67 -0
- package/backend/convex/connections.ts +28 -0
- package/backend/convex/crons.ts +18 -0
- package/backend/convex/devices.ts +224 -0
- package/backend/convex/http.ts +56 -0
- package/backend/convex/lib/activity.ts +155 -0
- package/backend/convex/lib/agentControls.ts +146 -0
- package/backend/convex/lib/agentWork.ts +250 -0
- package/backend/convex/lib/appToken.ts +68 -0
- package/backend/convex/lib/approvals.ts +311 -0
- package/backend/convex/lib/args.ts +175 -0
- package/backend/convex/lib/backlogMd.ts +309 -0
- package/backend/convex/lib/backlogOps.ts +361 -0
- package/backend/convex/lib/brief.ts +132 -0
- package/backend/convex/lib/cimd.ts +57 -0
- package/backend/convex/lib/core.ts +380 -0
- package/backend/convex/lib/dates.ts +114 -0
- package/backend/convex/lib/env.ts +19 -0
- package/backend/convex/lib/exportPlan.ts +98 -0
- package/backend/convex/lib/findTasks.ts +119 -0
- package/backend/convex/lib/frontmatter.ts +165 -0
- package/backend/convex/lib/guide.ts +286 -0
- package/backend/convex/lib/health.ts +115 -0
- package/backend/convex/lib/http.ts +27 -0
- package/backend/convex/lib/ical.ts +243 -0
- package/backend/convex/lib/importFormats.ts +429 -0
- package/backend/convex/lib/importOps.ts +145 -0
- package/backend/convex/lib/jsonSchema.ts +50 -0
- package/backend/convex/lib/limits.ts +42 -0
- package/backend/convex/lib/mcpContent.ts +220 -0
- package/backend/convex/lib/mcpProtocol.ts +128 -0
- package/backend/convex/lib/mcpTools.ts +505 -0
- package/backend/convex/lib/oauthConfig.ts +59 -0
- package/backend/convex/lib/oauthPage.ts +125 -0
- package/backend/convex/lib/oauthRules.ts +159 -0
- package/backend/convex/lib/openapi.ts +230 -0
- package/backend/convex/lib/outputSchemas.ts +2901 -0
- package/backend/convex/lib/problem.ts +69 -0
- package/backend/convex/lib/proposals.ts +172 -0
- package/backend/convex/lib/repeat.ts +185 -0
- package/backend/convex/lib/restRoutes.ts +294 -0
- package/backend/convex/lib/rrule.ts +171 -0
- package/backend/convex/lib/secrets.ts +135 -0
- package/backend/convex/lib/settingsOps.ts +47 -0
- package/backend/convex/lib/taskOps.ts +304 -0
- package/backend/convex/lib/toolResults.ts +50 -0
- package/backend/convex/lib/validators.ts +143 -0
- package/backend/convex/lib/views.ts +145 -0
- package/backend/convex/lib/vtimezone.ts +151 -0
- package/backend/convex/lib/webhookEvents.ts +160 -0
- package/backend/convex/lib/webhookSign.ts +30 -0
- package/backend/convex/lib/webhookTarget.ts +75 -0
- package/backend/convex/mcp.ts +405 -0
- package/backend/convex/migrations.ts +91 -0
- package/backend/convex/oauth.ts +388 -0
- package/backend/convex/oauthHttp.ts +381 -0
- package/backend/convex/oauthOwner.ts +173 -0
- package/backend/convex/ops.ts +205 -0
- package/backend/convex/pairing.ts +178 -0
- package/backend/convex/rest.ts +254 -0
- package/backend/convex/restAccount.ts +56 -0
- package/backend/convex/restImport.ts +73 -0
- package/backend/convex/schema.ts +435 -0
- package/backend/convex/tsconfig.json +18 -0
- package/backend/convex/webhooks.ts +339 -0
- package/backend/package-lock.json +3068 -0
- package/backend/package.json +33 -0
- package/backend/scripts/app-token.mjs +79 -0
- package/backend/scripts/fetch-local-backend.mjs +72 -0
- package/backend/scripts/gen-output-schemas.mjs +60 -0
- package/backend/scripts/jwt.mjs +67 -0
- package/backend/scripts/local-backend-lib.mjs +123 -0
- package/backend/scripts/local-backend.mjs +81 -0
- package/backend/scripts/setup-convex.mjs +158 -0
- package/backend/scripts/setup-lib.mjs +338 -0
- package/backend/scripts/setup-output.mjs +108 -0
- package/backend/scripts/setup.mjs +344 -0
- package/backend/scripts/signing-key.mjs +61 -0
- package/backend/tsconfig.json +16 -0
- package/dist/backlog/folder.js +114 -0
- package/dist/bin/dayhand.js +10 -0
- package/dist/bin/hlm.js +12 -0
- package/dist/completion/cache.js +54 -0
- package/dist/completion/engine.js +187 -0
- package/dist/completion/scripts.js +82 -0
- package/dist/config/aliases.js +25 -0
- package/dist/config/env.js +68 -0
- package/dist/config/file.js +146 -0
- package/dist/config/paths.js +58 -0
- package/dist/config/resolve.js +53 -0
- package/dist/config/url.js +26 -0
- package/dist/core/editor.js +37 -0
- package/dist/core/exit.js +59 -0
- package/dist/core/io.js +14 -0
- package/dist/core/output.js +39 -0
- package/dist/core/prompt.js +55 -0
- package/dist/core/version.js +15 -0
- package/dist/core/words.js +36 -0
- package/dist/credentials/file.js +65 -0
- package/dist/credentials/index.js +29 -0
- package/dist/credentials/keychain.js +44 -0
- package/dist/credentials/store.js +39 -0
- package/dist/docs.js +116 -0
- package/dist/fields.js +33 -0
- package/dist/globals.js +16 -0
- package/dist/guide.js +36 -0
- package/dist/help.js +56 -0
- package/dist/i18n/dates.js +84 -0
- package/dist/i18n/de.js +240 -0
- package/dist/i18n/en.js +243 -0
- package/dist/i18n/es.js +240 -0
- package/dist/i18n/fr.js +240 -0
- package/dist/i18n/index.js +67 -0
- package/dist/i18n/ja.js +240 -0
- package/dist/i18n/pl.js +245 -0
- package/dist/i18n/pt-BR.js +240 -0
- package/dist/i18n/zh-Hans.js +240 -0
- package/dist/main.js +180 -0
- package/dist/man.js +56 -0
- package/dist/registry/agents.js +67 -0
- package/dist/registry/auth.js +168 -0
- package/dist/registry/backlog.js +106 -0
- package/dist/registry/calendar.js +103 -0
- package/dist/registry/dates.js +33 -0
- package/dist/registry/filters.js +98 -0
- package/dist/registry/index.js +24 -0
- package/dist/registry/integrations.js +245 -0
- package/dist/registry/local.js +89 -0
- package/dist/registry/model.js +104 -0
- package/dist/registry/ops.js +47 -0
- package/dist/registry/owner.js +349 -0
- package/dist/registry/packaged-backend.js +59 -0
- package/dist/registry/parse.js +129 -0
- package/dist/registry/server.js +252 -0
- package/dist/registry/system.js +220 -0
- package/dist/registry/tasks.js +512 -0
- package/dist/registry/types.js +1 -0
- package/dist/registry/ui.js +51 -0
- package/dist/registry/work.js +207 -0
- package/dist/render/format.js +146 -0
- package/dist/render/model.js +29 -0
- package/dist/render/style.js +24 -0
- package/dist/render/table.js +46 -0
- package/dist/render/views.js +256 -0
- package/dist/run.js +133 -0
- package/dist/setup.js +26 -0
- package/dist/skill.js +83 -0
- package/dist/topics.js +121 -0
- package/dist/transport/client.js +196 -0
- package/dist/ui/agents.js +12 -0
- package/dist/ui/api.js +99 -0
- package/dist/ui/app.js +62 -0
- package/dist/ui/budget.js +42 -0
- package/dist/ui/controller.js +368 -0
- package/dist/ui/effects.js +166 -0
- package/dist/ui/frame.js +206 -0
- package/dist/ui/handle.js +271 -0
- package/dist/ui/health.js +22 -0
- package/dist/ui/keys.js +120 -0
- package/dist/ui/line.js +56 -0
- package/dist/ui/lists.js +58 -0
- package/dist/ui/model.js +25 -0
- package/dist/ui/quickadd.js +66 -0
- package/dist/ui/rows.js +68 -0
- package/dist/ui/run.js +54 -0
- package/dist/ui/screens/agents.js +65 -0
- package/dist/ui/screens/board.js +41 -0
- package/dist/ui/screens/detail.js +142 -0
- package/dist/ui/screens/overlay.js +58 -0
- package/dist/ui/screens/screen.js +3 -0
- package/dist/ui/screens/today.js +60 -0
- package/dist/ui/screens/week.js +76 -0
- package/dist/ui/state.js +128 -0
- package/dist/ui/text.js +70 -0
- package/dist/when/calendar.js +95 -0
- package/dist/when/parse.js +314 -0
- package/man/dayhand.1 +1163 -0
- package/man/dh.1 +1 -0
- package/package.json +56 -4
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The activity log (list_events, dayhand://activity, dayhand log): the user's
|
|
2
|
+
// task changes in the order they were committed, read from a position on, so
|
|
3
|
+
// an agent back from a pause gets only what changed since its own cursor.
|
|
4
|
+
// Positions are taskEvents.seq (lib/core.ts recordEvent); a cursor is
|
|
5
|
+
// "c<seq>" and means "after this change".
|
|
6
|
+
import type { ObjectType } from "convex/values";
|
|
7
|
+
import type { Doc, Id } from "../_generated/dataModel";
|
|
8
|
+
import type { QueryCtx } from "../_generated/server";
|
|
9
|
+
import type { args } from "./args";
|
|
10
|
+
import { actorLabel, fail, loadTask, type Identity } from "./core";
|
|
11
|
+
|
|
12
|
+
type Args = ObjectType<(typeof args)["listEvents"]>;
|
|
13
|
+
|
|
14
|
+
export const MAX_EVENTS = 500;
|
|
15
|
+
const DEFAULT_EVENTS = 100;
|
|
16
|
+
/** Changes looked at per call when filters skip most of them; the cursor continues from there. */
|
|
17
|
+
const SCAN_LIMIT = 4_000;
|
|
18
|
+
|
|
19
|
+
const CURSOR = /^c(\d{1,17})$/;
|
|
20
|
+
const UNIX_MS = /^\d{1,15}$/;
|
|
21
|
+
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
22
|
+
|
|
23
|
+
export const cursorOf = (seq: number) => `c${seq}`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The position to read after: a cursor from an earlier call, or a time (Unix
|
|
27
|
+
* milliseconds or ISO 8601 with a zone) meaning the changes made from then on.
|
|
28
|
+
*/
|
|
29
|
+
function positionOf(since: string): number {
|
|
30
|
+
const cursor = CURSOR.exec(since);
|
|
31
|
+
if (cursor) return Number(cursor[1]);
|
|
32
|
+
const at = UNIX_MS.test(since) ? Number(since) : ISO_TIME.test(since) ? Date.parse(since) : NaN;
|
|
33
|
+
if (Number.isNaN(at)) {
|
|
34
|
+
fail("invalid_argument", `since must be a cursor from an earlier call ("c…"), Unix milliseconds or an ISO 8601 time, got "${since}"`);
|
|
35
|
+
}
|
|
36
|
+
// Positions start from the change's time in microseconds (lib/core.ts).
|
|
37
|
+
return at * 1000 - 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type ActorFilter = (event: Doc<"taskEvents">) => boolean;
|
|
41
|
+
|
|
42
|
+
/** "app", "cli", "agent" (any agent) or "agent: <name>" (one key or connection, as history's `by`). */
|
|
43
|
+
function actorFilter(by: string | undefined): ActorFilter {
|
|
44
|
+
if (by === undefined) return () => true;
|
|
45
|
+
const wanted = by.trim();
|
|
46
|
+
if (wanted === "app" || wanted === "cli" || wanted === "agent") return (event) => event.actor === wanted;
|
|
47
|
+
if (/^agent: .+/.test(wanted)) return (event) => actorLabel(event) === wanted;
|
|
48
|
+
return fail("invalid_argument", `by must be app, cli, agent or "agent: <name>", got "${by}"`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkLimit(limit: number | undefined): number {
|
|
52
|
+
if (limit === undefined) return DEFAULT_EVENTS;
|
|
53
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_EVENTS) fail("invalid_argument", `limit must be a whole number from 1 to ${MAX_EVENTS}`);
|
|
54
|
+
return limit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function view(ctx: QueryCtx, event: Doc<"taskEvents">) {
|
|
58
|
+
const task = event.taskId ? await ctx.db.get(event.taskId) : null;
|
|
59
|
+
return {
|
|
60
|
+
id: event._id,
|
|
61
|
+
at: event.at,
|
|
62
|
+
taskId: event.taskId ?? null,
|
|
63
|
+
ref: task?.ref ?? null,
|
|
64
|
+
title: task?.title ?? null,
|
|
65
|
+
action: event.action,
|
|
66
|
+
actor: event.actor,
|
|
67
|
+
actorName: event.actorName ?? null,
|
|
68
|
+
by: actorLabel(event),
|
|
69
|
+
before: event.before ?? null,
|
|
70
|
+
after: event.after ?? null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The newest position in the user's log, or 0 before the first task change. */
|
|
75
|
+
async function head(ctx: QueryCtx, userId: string): Promise<number> {
|
|
76
|
+
const last = await ctx.db
|
|
77
|
+
.query("taskEvents")
|
|
78
|
+
.withIndex("by_user_seq", (q) => q.eq("userId", userId).gt("seq", 0))
|
|
79
|
+
.order("desc")
|
|
80
|
+
.first();
|
|
81
|
+
return last?.seq ?? 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Page = { events: Doc<"taskEvents">[]; position: number; hasMore: boolean };
|
|
85
|
+
|
|
86
|
+
/** Changes after `after`, oldest first; stops at `limit` matches or SCAN_LIMIT looked at. */
|
|
87
|
+
async function forward(ctx: QueryCtx, userId: string, after: number, limit: number, matches: ActorFilter): Promise<Page> {
|
|
88
|
+
const events: Doc<"taskEvents">[] = [];
|
|
89
|
+
let position = after;
|
|
90
|
+
let scanned = 0;
|
|
91
|
+
for await (const event of ctx.db
|
|
92
|
+
.query("taskEvents")
|
|
93
|
+
.withIndex("by_user_seq", (q) => q.eq("userId", userId).gt("seq", after))) {
|
|
94
|
+
if (events.length >= limit || scanned >= SCAN_LIMIT) return { events, position, hasMore: true };
|
|
95
|
+
scanned++;
|
|
96
|
+
position = event.seq!;
|
|
97
|
+
if (matches(event)) events.push(event);
|
|
98
|
+
}
|
|
99
|
+
return { events, position, hasMore: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The newest `limit` matching changes, oldest first, among the last SCAN_LIMIT. */
|
|
103
|
+
async function latest(ctx: QueryCtx, userId: string, limit: number, matches: ActorFilter): Promise<Page> {
|
|
104
|
+
const events: Doc<"taskEvents">[] = [];
|
|
105
|
+
let scanned = 0;
|
|
106
|
+
for await (const event of ctx.db
|
|
107
|
+
.query("taskEvents")
|
|
108
|
+
.withIndex("by_user_seq", (q) => q.eq("userId", userId).gt("seq", 0))
|
|
109
|
+
.order("desc")) {
|
|
110
|
+
if (events.length >= limit || scanned >= SCAN_LIMIT) break;
|
|
111
|
+
scanned++;
|
|
112
|
+
if (matches(event)) events.push(event);
|
|
113
|
+
}
|
|
114
|
+
return { events: events.reverse(), position: await head(ctx, userId), hasMore: false };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One task's changes: few enough to read whole, then narrowed like the user's log. */
|
|
118
|
+
async function ofTask(ctx: QueryCtx, userId: string, taskId: Id<"tasks">, after: number | null, limit: number, matches: ActorFilter): Promise<Page> {
|
|
119
|
+
const all = (
|
|
120
|
+
await ctx.db
|
|
121
|
+
.query("taskEvents")
|
|
122
|
+
.withIndex("by_task", (q) => q.eq("taskId", taskId))
|
|
123
|
+
.collect()
|
|
124
|
+
)
|
|
125
|
+
.filter((e) => e.seq !== undefined && matches(e))
|
|
126
|
+
.sort((a, b) => a.seq! - b.seq!);
|
|
127
|
+
if (after === null) return { events: all.slice(-limit), position: await head(ctx, userId), hasMore: false };
|
|
128
|
+
const rest = all.filter((e) => e.seq! > after);
|
|
129
|
+
const events = rest.slice(0, limit);
|
|
130
|
+
const position = events.length > 0 ? events[events.length - 1]!.seq! : Math.max(after, 0);
|
|
131
|
+
return { events, position, hasMore: rest.length > events.length };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Task changes after `since`, oldest first, with the cursor to pass as
|
|
136
|
+
* `since` next time. Without `since`: the newest changes and the cursor of
|
|
137
|
+
* the newest one, to start following the log from now.
|
|
138
|
+
*/
|
|
139
|
+
export async function listEvents(ctx: QueryCtx, identity: Identity, input: Args) {
|
|
140
|
+
const limit = checkLimit(input.limit);
|
|
141
|
+
const matches = actorFilter(input.by);
|
|
142
|
+
const after = input.since === undefined ? null : positionOf(input.since.trim());
|
|
143
|
+
let page: Page;
|
|
144
|
+
if (input.taskId !== undefined) {
|
|
145
|
+
const task = await loadTask(ctx, identity.userId, input.taskId);
|
|
146
|
+
page = await ofTask(ctx, identity.userId, task._id, after, limit, matches);
|
|
147
|
+
} else if (after === null) {
|
|
148
|
+
page = await latest(ctx, identity.userId, limit, matches);
|
|
149
|
+
} else {
|
|
150
|
+
page = await forward(ctx, identity.userId, after, limit, matches);
|
|
151
|
+
}
|
|
152
|
+
const events = [];
|
|
153
|
+
for (const event of page.events) events.push(await view(ctx, event));
|
|
154
|
+
return { events, nextCursor: cursorOf(Math.max(page.position, 0)), hasMore: page.hasMore };
|
|
155
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The owner's controls over agents (docs/agent-work.md#6-pausing-agents-and-tool-policies):
|
|
2
|
+
// one switch that pauses every agent, and per agent key a rule for each write
|
|
3
|
+
// tool: allow, ask (an approval request first, lib/approvals.ts) or deny. Reads
|
|
4
|
+
// are never refused. Every change an agent makes passes gateAgentWrite, in the
|
|
5
|
+
// same transaction as the change (ops.ts).
|
|
6
|
+
import type { ObjectType } from "convex/values";
|
|
7
|
+
import type { Doc, Id } from "../_generated/dataModel";
|
|
8
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server";
|
|
9
|
+
import type { args } from "./args";
|
|
10
|
+
import { usePolicyApproval } from "./approvals";
|
|
11
|
+
import { fail, ownerOnly, recordEvent, type Identity } from "./core";
|
|
12
|
+
import type { PolicyRule } from "./validators";
|
|
13
|
+
|
|
14
|
+
type Args<K extends keyof typeof args> = ObjectType<(typeof args)[K]>;
|
|
15
|
+
|
|
16
|
+
const MAX_REASON = 500;
|
|
17
|
+
/** The rule of every write tool of a key that has no rule of its own. */
|
|
18
|
+
export const ANY_TOOL = "*";
|
|
19
|
+
|
|
20
|
+
async function controlsRow(ctx: QueryCtx, userId: string) {
|
|
21
|
+
return ctx.db
|
|
22
|
+
.query("agentControls")
|
|
23
|
+
.withIndex("by_user", (q) => q.eq("userId", userId))
|
|
24
|
+
.unique();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The rule for one key and one write tool: its own row, else the key's "*" row, else allow. */
|
|
28
|
+
export async function ruleOf(ctx: QueryCtx, keyId: Id<"apiKeys">, tool: string): Promise<PolicyRule> {
|
|
29
|
+
for (const name of [tool, ANY_TOOL]) {
|
|
30
|
+
const row = await ctx.db
|
|
31
|
+
.query("toolPolicies")
|
|
32
|
+
.withIndex("by_key_tool", (q) => q.eq("keyId", keyId).eq("tool", name))
|
|
33
|
+
.unique();
|
|
34
|
+
if (row) return row.rule;
|
|
35
|
+
}
|
|
36
|
+
return "allow";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Refuses a change by an agent while agents are paused, or when the owner's
|
|
41
|
+
* policy denies the tool to its key; with "ask" the call needs an approval
|
|
42
|
+
* request decided "allow" (approvalId), used up by this change. A dry run
|
|
43
|
+
* changes nothing and needs no approval. The owner (app, cli) is never refused.
|
|
44
|
+
*/
|
|
45
|
+
export async function gateAgentWrite(
|
|
46
|
+
ctx: MutationCtx,
|
|
47
|
+
identity: Identity,
|
|
48
|
+
call: { tool?: string; approvalId?: string; dryRun?: boolean; input: Record<string, unknown> },
|
|
49
|
+
) {
|
|
50
|
+
if (identity.actor !== "agent") return;
|
|
51
|
+
const controls = await controlsRow(ctx, identity.userId);
|
|
52
|
+
if (controls?.pausedAt !== undefined) {
|
|
53
|
+
const reason = controls.reason ? ` (${controls.reason})` : "";
|
|
54
|
+
fail("agents_paused", `The owner has paused all agents${reason}: reading works, changes are refused until they resume agents`);
|
|
55
|
+
}
|
|
56
|
+
if (!call.tool || !identity.keyId) return;
|
|
57
|
+
const rule = await ruleOf(ctx, identity.keyId, call.tool);
|
|
58
|
+
if (rule === "deny") fail("forbidden", `The owner's policy does not let this key call ${call.tool}`);
|
|
59
|
+
if (rule === "allow" || call.dryRun) return;
|
|
60
|
+
if (call.approvalId === undefined) {
|
|
61
|
+
fail("approval_required", `The owner's policy asks before this key calls ${call.tool}`);
|
|
62
|
+
}
|
|
63
|
+
await usePolicyApproval(ctx, identity, call.tool, call.input, call.approvalId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether agents are paused: for the account routes of the REST API, which run no domain change. */
|
|
67
|
+
export async function agentsPaused(ctx: QueryCtx, userId: string): Promise<boolean> {
|
|
68
|
+
return (await controlsRow(ctx, userId))?.pausedAt !== undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- Views and the owner's changes ----
|
|
72
|
+
|
|
73
|
+
async function policyViews(ctx: QueryCtx, userId: string) {
|
|
74
|
+
const rows = await ctx.db
|
|
75
|
+
.query("toolPolicies")
|
|
76
|
+
.withIndex("by_user", (q) => q.eq("userId", userId))
|
|
77
|
+
.collect();
|
|
78
|
+
const keys = new Map<string, Doc<"apiKeys"> | null>();
|
|
79
|
+
for (const row of rows) if (!keys.has(row.keyId)) keys.set(row.keyId, await ctx.db.get(row.keyId));
|
|
80
|
+
return rows
|
|
81
|
+
.filter((row) => keys.get(row.keyId)?.revokedAt === undefined && keys.get(row.keyId) !== null)
|
|
82
|
+
.map((row) => ({ keyId: row.keyId, keyName: keys.get(row.keyId)!.name, tool: row.tool, rule: row.rule, updatedAt: row.updatedAt }))
|
|
83
|
+
.sort((a, b) => a.keyName.localeCompare(b.keyName) || a.tool.localeCompare(b.tool));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** get_agent_controls: whether agents are paused, and every rule the owner set. */
|
|
87
|
+
export async function getAgentControls(ctx: QueryCtx, identity: Identity) {
|
|
88
|
+
const row = await controlsRow(ctx, identity.userId);
|
|
89
|
+
const paused = row?.pausedAt !== undefined;
|
|
90
|
+
return {
|
|
91
|
+
paused,
|
|
92
|
+
pausedAt: row?.pausedAt ?? null,
|
|
93
|
+
pausedBy: paused ? (row?.pausedBy ?? null) : null,
|
|
94
|
+
reason: paused ? (row?.reason ?? null) : null,
|
|
95
|
+
policies: await policyViews(ctx, identity.userId),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** pause_agents: the owner pauses every agent, or lets them run again. */
|
|
100
|
+
export async function setAgentsPaused(ctx: MutationCtx, identity: Identity, input: Args<"pauseAgents">) {
|
|
101
|
+
ownerOnly(identity, "pause or resume agents");
|
|
102
|
+
const reason = input.reason?.trim();
|
|
103
|
+
if (reason && reason.length > MAX_REASON) fail("invalid_argument", `reason is longer than ${MAX_REASON} characters`);
|
|
104
|
+
const row = await controlsRow(ctx, identity.userId);
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const wasPaused = row?.pausedAt !== undefined;
|
|
107
|
+
const fields = input.paused
|
|
108
|
+
? { pausedAt: wasPaused ? row!.pausedAt : now, pausedBy: identity.actor, reason: reason || undefined, updatedAt: now }
|
|
109
|
+
: { pausedAt: undefined, pausedBy: undefined, reason: undefined, updatedAt: now };
|
|
110
|
+
if (row) await ctx.db.patch(row._id, fields);
|
|
111
|
+
else await ctx.db.insert("agentControls", { userId: identity.userId, ...fields });
|
|
112
|
+
if (wasPaused !== input.paused) {
|
|
113
|
+
await recordEvent(ctx, identity, { action: input.paused ? "agents.pause" : "agents.resume", after: input.paused ? { reason: reason ?? null } : {} });
|
|
114
|
+
}
|
|
115
|
+
return getAgentControls(ctx, identity);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** set_tool_policy: the owner's rule for one agent key and one write tool (or "*"); allow removes the rule. */
|
|
119
|
+
export async function setToolPolicy(ctx: MutationCtx, identity: Identity, input: Args<"setToolPolicy">, writeTools: readonly string[]) {
|
|
120
|
+
ownerOnly(identity, "change tool policies");
|
|
121
|
+
const keyId = ctx.db.normalizeId("apiKeys", input.apiKeyId);
|
|
122
|
+
const key = keyId ? await ctx.db.get(keyId) : null;
|
|
123
|
+
if (!keyId || !key || key.userId !== identity.userId || key.revokedAt !== undefined) fail("not_found", `API key ${input.apiKeyId} not found`);
|
|
124
|
+
if ((key.kind ?? "agent") !== "agent") fail("invalid_argument", "Policies are for agent keys; a cli key is the owner at a terminal");
|
|
125
|
+
const tool = input.toolName;
|
|
126
|
+
if (tool !== ANY_TOOL && !writeTools.includes(tool)) {
|
|
127
|
+
fail("invalid_argument", `toolName must be "*" or a tool that changes the plan: ${writeTools.join(", ")}`);
|
|
128
|
+
}
|
|
129
|
+
const existing = await ctx.db
|
|
130
|
+
.query("toolPolicies")
|
|
131
|
+
.withIndex("by_key_tool", (q) => q.eq("keyId", keyId).eq("tool", tool))
|
|
132
|
+
.unique();
|
|
133
|
+
const before = existing?.rule ?? null;
|
|
134
|
+
// "allow" on a single tool is kept when the key's "*" rule says otherwise; otherwise allow is the default.
|
|
135
|
+
const keepAllow = input.rule === "allow" && tool !== ANY_TOOL && (await ruleOf(ctx, keyId, ANY_TOOL)) !== "allow";
|
|
136
|
+
if (input.rule === "allow" && !keepAllow) {
|
|
137
|
+
if (existing) await ctx.db.delete(existing._id);
|
|
138
|
+
} else if (existing) {
|
|
139
|
+
if (existing.rule !== input.rule) await ctx.db.patch(existing._id, { rule: input.rule, updatedAt: Date.now() });
|
|
140
|
+
}
|
|
141
|
+
else await ctx.db.insert("toolPolicies", { userId: identity.userId, keyId, tool, rule: input.rule, updatedAt: Date.now() });
|
|
142
|
+
if (before !== input.rule) {
|
|
143
|
+
await recordEvent(ctx, identity, { action: "policy.set", before: { rule: before }, after: { keyId, keyName: key.name, tool, rule: input.rule } });
|
|
144
|
+
}
|
|
145
|
+
return getAgentControls(ctx, identity);
|
|
146
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Agent work on a task (docs/agent-work.md): acceptance criteria and a plan,
|
|
2
|
+
// proof of completion, dependencies and a blocked reason, and a time-boxed
|
|
3
|
+
// claim. No new statuses: everything here is fields next to todo/doing/done.
|
|
4
|
+
import type { ObjectType } from "convex/values";
|
|
5
|
+
import type { Doc, Id } from "../_generated/dataModel";
|
|
6
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server";
|
|
7
|
+
import type { args } from "./args";
|
|
8
|
+
import { checkLinks, checkText, fail, loadTask, patchTask, replay, toTask, type Identity } from "./core";
|
|
9
|
+
import type { AcceptanceInput, AcceptanceItem, ClaimHolder, Proof } from "./validators";
|
|
10
|
+
|
|
11
|
+
type Args<K extends keyof typeof args> = ObjectType<(typeof args)[K]>;
|
|
12
|
+
|
|
13
|
+
const LIMITS = {
|
|
14
|
+
acceptance: 50,
|
|
15
|
+
acceptanceId: 64,
|
|
16
|
+
acceptanceText: 500,
|
|
17
|
+
plan: 20_000,
|
|
18
|
+
blockedReason: 2_000,
|
|
19
|
+
dependsOn: 50,
|
|
20
|
+
summary: 4_000,
|
|
21
|
+
checks: 50,
|
|
22
|
+
checkName: 200,
|
|
23
|
+
url: 2_048,
|
|
24
|
+
claimMinutes: 24 * 60,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_CLAIM_MINUTES = 120;
|
|
28
|
+
|
|
29
|
+
// ---- Acceptance criteria, plan, blocked reason ----
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The criteria as stored: trimmed texts in the given order, `met` false unless
|
|
33
|
+
* set, and an id for every one. New criteria get the next free `a<n>`. An
|
|
34
|
+
* empty list is no criteria.
|
|
35
|
+
*/
|
|
36
|
+
export function checkAcceptance(items: AcceptanceInput[]): AcceptanceItem[] | undefined {
|
|
37
|
+
if (items.length > LIMITS.acceptance) fail("invalid_argument", `at most ${LIMITS.acceptance} acceptance criteria`);
|
|
38
|
+
const given = items.map((item) => item.id).filter((id): id is string => id !== undefined);
|
|
39
|
+
if (given.some((id) => id.trim() === "" || id.length > LIMITS.acceptanceId)) {
|
|
40
|
+
fail("invalid_argument", `acceptance criterion ids must be 1 to ${LIMITS.acceptanceId} characters`);
|
|
41
|
+
}
|
|
42
|
+
if (new Set(given).size !== given.length) fail("invalid_argument", "acceptance criterion ids must be distinct");
|
|
43
|
+
const used = new Set(given);
|
|
44
|
+
let next = 1;
|
|
45
|
+
const freeId = () => {
|
|
46
|
+
while (used.has(`a${next}`)) next++;
|
|
47
|
+
used.add(`a${next}`);
|
|
48
|
+
return `a${next}`;
|
|
49
|
+
};
|
|
50
|
+
const result = items.map((item) => {
|
|
51
|
+
const text = item.text.trim();
|
|
52
|
+
if (!text) fail("invalid_argument", "acceptance criteria need a text");
|
|
53
|
+
if (text.length > LIMITS.acceptanceText) fail("invalid_argument", `acceptance criteria are at most ${LIMITS.acceptanceText} characters`);
|
|
54
|
+
return { id: item.id ?? freeId(), text, met: item.met ?? false };
|
|
55
|
+
});
|
|
56
|
+
return result.length > 0 ? result : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function checkLongText(field: "plan" | "blockedReason", value: string): string | undefined {
|
|
60
|
+
if (value.length > LIMITS[field]) fail("invalid_argument", `${field} is longer than ${LIMITS[field]} characters`);
|
|
61
|
+
return value.trim() === "" ? undefined : value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- Proof of completion ----
|
|
65
|
+
|
|
66
|
+
function checkUrl(field: string, url: string): string {
|
|
67
|
+
let parsed: URL;
|
|
68
|
+
try {
|
|
69
|
+
parsed = new URL(url);
|
|
70
|
+
} catch {
|
|
71
|
+
fail("invalid_argument", `${field}: invalid URL "${url}"`);
|
|
72
|
+
}
|
|
73
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || url.length > LIMITS.url) {
|
|
74
|
+
fail("invalid_argument", `${field}: URLs must be http(s), up to ${LIMITS.url} characters`);
|
|
75
|
+
}
|
|
76
|
+
return url;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The proof as stored: a trimmed summary, checked links and checks; empty parts left out. */
|
|
80
|
+
export function checkProof(proof: Proof): Proof {
|
|
81
|
+
const summary = proof.summary?.trim();
|
|
82
|
+
if (summary && summary.length > LIMITS.summary) fail("invalid_argument", `proof.summary is longer than ${LIMITS.summary} characters`);
|
|
83
|
+
const checks = proof.checks ?? [];
|
|
84
|
+
if (checks.length > LIMITS.checks) fail("invalid_argument", `at most ${LIMITS.checks} proof checks`);
|
|
85
|
+
const cleanChecks = checks.map((check) => {
|
|
86
|
+
const name = check.name.trim();
|
|
87
|
+
if (!name || name.length > LIMITS.checkName) fail("invalid_argument", `proof checks need a name of 1 to ${LIMITS.checkName} characters`);
|
|
88
|
+
return check.url === undefined ? { name, result: check.result } : { name, result: check.result, url: checkUrl("proof.checks", check.url) };
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
...(summary ? { summary } : {}),
|
|
92
|
+
links: checkLinks(proof.links ?? []),
|
|
93
|
+
checks: cleanChecks,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* An agent may complete a task with acceptance criteria only when every
|
|
99
|
+
* criterion is met and the proof has a summary. People (app, cli) complete
|
|
100
|
+
* tasks as before.
|
|
101
|
+
*/
|
|
102
|
+
export function checkCompletion(task: Doc<"tasks">, identity: Identity, proof: Proof | undefined) {
|
|
103
|
+
const criteria = task.acceptance ?? [];
|
|
104
|
+
if (identity.actor !== "agent" || criteria.length === 0) return;
|
|
105
|
+
const unmet = criteria.filter((item) => !item.met);
|
|
106
|
+
if (unmet.length > 0) {
|
|
107
|
+
fail("precondition_failed", `Acceptance criteria not met: ${unmet.map((item) => item.id).join(", ")}; mark them met with update_task first`);
|
|
108
|
+
}
|
|
109
|
+
if (!proof?.summary) fail("precondition_failed", "A task with acceptance criteria needs proof with a summary to be completed by an agent");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- Dependencies ----
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The dependencies as stored: the tasks' ids, each once. A task cannot depend
|
|
116
|
+
* on itself or on a task that (through its own dependencies) depends on it.
|
|
117
|
+
*/
|
|
118
|
+
export async function checkDependsOn(ctx: QueryCtx, userId: string, taskId: Id<"tasks"> | null, refs: string[]): Promise<Id<"tasks">[] | undefined> {
|
|
119
|
+
if (refs.length > LIMITS.dependsOn) fail("invalid_argument", `at most ${LIMITS.dependsOn} dependencies`);
|
|
120
|
+
const ids: Id<"tasks">[] = [];
|
|
121
|
+
for (const ref of refs) {
|
|
122
|
+
const dependency = await loadTask(ctx, userId, ref);
|
|
123
|
+
if (dependency._id === taskId) fail("invalid_argument", "A task cannot depend on itself");
|
|
124
|
+
if (!ids.includes(dependency._id)) ids.push(dependency._id);
|
|
125
|
+
}
|
|
126
|
+
if (taskId !== null) await refuseCycle(ctx, taskId, ids);
|
|
127
|
+
return ids.length > 0 ? ids : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function refuseCycle(ctx: QueryCtx, taskId: Id<"tasks">, start: Id<"tasks">[]) {
|
|
131
|
+
const seen = new Set<string>();
|
|
132
|
+
const pending = [...start];
|
|
133
|
+
while (pending.length > 0) {
|
|
134
|
+
const id = pending.pop()!;
|
|
135
|
+
if (id === taskId) fail("invalid_argument", "These dependencies would make a cycle: a task would wait for itself");
|
|
136
|
+
if (seen.has(id)) continue;
|
|
137
|
+
seen.add(id);
|
|
138
|
+
const task = await ctx.db.get(id);
|
|
139
|
+
pending.push(...(task?.dependsOn ?? []));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Ready to work on: not done, not blocked, not a proposal, and every dependency
|
|
145
|
+
* done. A dependency in the trash or purged no longer holds the task back.
|
|
146
|
+
*/
|
|
147
|
+
export function isReady(task: Doc<"tasks">, byId: Map<string, Doc<"tasks">>): boolean {
|
|
148
|
+
if (task.status === "done" || task.blockedReason !== undefined || task.proposedAt !== undefined) return false;
|
|
149
|
+
return (task.dependsOn ?? []).every((id) => {
|
|
150
|
+
const dependency = byId.get(id);
|
|
151
|
+
return dependency === undefined || dependency.deletedAt !== undefined || dependency.status === "done";
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- Claims ----
|
|
156
|
+
|
|
157
|
+
/** The caller as a claim holder; only an API key or an agent connection can hold a claim. */
|
|
158
|
+
function holderOf(identity: Identity): ClaimHolder {
|
|
159
|
+
if (identity.keyId) return { keyId: identity.keyId, name: identity.actorName ?? "API key" };
|
|
160
|
+
if (identity.connectionId) return { connectionId: identity.connectionId, name: identity.actorName ?? "connection" };
|
|
161
|
+
return fail("invalid_argument", "Only an API key or an agent connection can claim a task");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Whether the caller (its API key or agent connection) is the holder. */
|
|
165
|
+
export const sameHolder = (a: ClaimHolder | undefined, identity: Pick<Identity, "keyId" | "connectionId">) =>
|
|
166
|
+
a !== undefined && ((a.keyId !== undefined && a.keyId === identity.keyId) || (a.connectionId !== undefined && a.connectionId === identity.connectionId));
|
|
167
|
+
|
|
168
|
+
/** The live claim of someone other than the caller, if any. */
|
|
169
|
+
function othersClaim(task: Doc<"tasks">, identity: Identity, now: number): ClaimHolder | null {
|
|
170
|
+
const live = task.claimedBy !== undefined && (task.claimExpiresAt ?? 0) > now;
|
|
171
|
+
return live && !sameHolder(task.claimedBy, identity) ? task.claimedBy! : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const until = (at: number) => new Date(at).toISOString();
|
|
175
|
+
|
|
176
|
+
function conflict(task: Doc<"tasks">, holder: ClaimHolder): never {
|
|
177
|
+
fail("conflict", `Task #${task.ref ?? task._id} is claimed by ${holder.name} until ${until(task.claimExpiresAt!)}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Refuses a change of a task that another agent has claimed. The owner wins:
|
|
182
|
+
* changes from the app and the CLI (the user) always go through.
|
|
183
|
+
*/
|
|
184
|
+
export function checkClaim(task: Doc<"tasks">, identity: Identity) {
|
|
185
|
+
if (identity.actor !== "agent") return;
|
|
186
|
+
const holder = othersClaim(task, identity, Date.now());
|
|
187
|
+
if (holder) conflict(task, holder);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function claimMinutes(minutes: number | undefined): number {
|
|
191
|
+
const value = minutes ?? DEFAULT_CLAIM_MINUTES;
|
|
192
|
+
if (!Number.isInteger(value) || value < 1 || value > LIMITS.claimMinutes) {
|
|
193
|
+
fail("invalid_argument", `minutes must be a whole number from 1 to ${LIMITS.claimMinutes}`);
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function claimable(task: Doc<"tasks">) {
|
|
199
|
+
if (task.deletedAt !== undefined) fail("invalid_argument", "Task is in the trash; restore it first");
|
|
200
|
+
if (task.archivedAt !== undefined) fail("invalid_argument", "Task is archived; restore it first");
|
|
201
|
+
if (task.status === "done") fail("invalid_argument", "Task is done; reopen it first");
|
|
202
|
+
if (task.proposedAt !== undefined) fail("invalid_argument", "Task is a proposal; it can be claimed once the owner accepts it");
|
|
203
|
+
return task;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Claims a task for `minutes` (default 120); claiming it again extends the caller's own claim. */
|
|
207
|
+
export async function claimTask(ctx: MutationCtx, identity: Identity, input: Args<"claim">) {
|
|
208
|
+
const replayed = await replay(ctx, identity.userId, input.clientId);
|
|
209
|
+
if (replayed) return toTask(replayed);
|
|
210
|
+
const task = claimable(await loadTask(ctx, identity.userId, input.taskId));
|
|
211
|
+
const holder = holderOf(identity);
|
|
212
|
+
const minutes = claimMinutes(input.minutes);
|
|
213
|
+
const other = othersClaim(task, identity, Date.now());
|
|
214
|
+
if (other) conflict(task, other);
|
|
215
|
+
const patch = { claimedBy: holder, claimExpiresAt: Date.now() + minutes * 60_000 };
|
|
216
|
+
return toTask(await patchTask(ctx, identity, task, "claim", patch, input.clientId));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Extends the caller's claim by `minutes` from now. It works after the claim
|
|
221
|
+
* expired too, as long as nobody else has claimed the task since.
|
|
222
|
+
*/
|
|
223
|
+
export async function renewClaim(ctx: MutationCtx, identity: Identity, input: Args<"renewClaim">) {
|
|
224
|
+
const replayed = await replay(ctx, identity.userId, input.clientId);
|
|
225
|
+
if (replayed) return toTask(replayed);
|
|
226
|
+
const task = claimable(await loadTask(ctx, identity.userId, input.taskId));
|
|
227
|
+
const minutes = claimMinutes(input.minutes);
|
|
228
|
+
if (!sameHolder(task.claimedBy, identity)) {
|
|
229
|
+
const other = othersClaim(task, identity, Date.now());
|
|
230
|
+
if (other) conflict(task, other);
|
|
231
|
+
fail("precondition_failed", "You do not hold a claim on this task; claim it with claim_task");
|
|
232
|
+
}
|
|
233
|
+
return toTask(await patchTask(ctx, identity, task, "renewClaim", { claimExpiresAt: Date.now() + minutes * 60_000 }, input.clientId));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Gives a claim up, optionally leaving a checkpoint as the resume note (where
|
|
238
|
+
* the work stopped, the next step). The holder releases its own claim; the
|
|
239
|
+
* owner (app, cli) may release anyone's, which is how a person takes over.
|
|
240
|
+
*/
|
|
241
|
+
export async function releaseTask(ctx: MutationCtx, identity: Identity, input: Args<"release">) {
|
|
242
|
+
const replayed = await replay(ctx, identity.userId, input.clientId);
|
|
243
|
+
if (replayed) return toTask(replayed);
|
|
244
|
+
const task = await loadTask(ctx, identity.userId, input.taskId);
|
|
245
|
+
checkClaim(task, identity);
|
|
246
|
+
const patch: Partial<Doc<"tasks">> = { claimedBy: undefined, claimExpiresAt: undefined };
|
|
247
|
+
const checkpoint = input.note === undefined ? undefined : checkText("resumeNote", input.note);
|
|
248
|
+
if (checkpoint !== undefined) patch.resumeNote = checkpoint;
|
|
249
|
+
return toTask(await patchTask(ctx, identity, task, "release", patch, input.clientId));
|
|
250
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Signs the app's ES256 JWT inside the deployment. The private key is the
|
|
2
|
+
// deployment environment variable DAYHAND_JWT_PRIVATE_JWK (a JWK with `kid`);
|
|
3
|
+
// its public half must be the key with the same `kid` in DAYHAND_JWKS, which
|
|
4
|
+
// auth.config.ts trusts. The private key is never returned to any client.
|
|
5
|
+
import { serverEnv } from "./env";
|
|
6
|
+
import { bytesToBase64url } from "./secrets";
|
|
7
|
+
|
|
8
|
+
export const APP_TOKEN_TTL_SECONDS = 3600;
|
|
9
|
+
const AUDIENCE = "helm";
|
|
10
|
+
const ALGORITHM = { name: "ECDSA", namedCurve: "P-256" };
|
|
11
|
+
|
|
12
|
+
export type SigningConfig = { issuer: string; privateJwk: JsonWebKey & { kid: string } };
|
|
13
|
+
|
|
14
|
+
export class SigningConfigError extends Error {}
|
|
15
|
+
|
|
16
|
+
function decodeJwks(dataUri: string): { keys: (JsonWebKey & { kid?: string })[] } {
|
|
17
|
+
const match = /^data:application\/json;base64,(.+)$/.exec(dataUri);
|
|
18
|
+
if (!match) throw new SigningConfigError("DAYHAND_JWKS is not a base64 JSON data URI");
|
|
19
|
+
return JSON.parse(atob(match[1]));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Reads and cross-checks the signing configuration; throws SigningConfigError when unusable. */
|
|
23
|
+
export function signingConfig(): SigningConfig {
|
|
24
|
+
const issuer = serverEnv("JWT_ISSUER");
|
|
25
|
+
const rawKey = serverEnv("JWT_PRIVATE_JWK");
|
|
26
|
+
const jwks = serverEnv("JWKS");
|
|
27
|
+
if (!issuer || !rawKey || !jwks) {
|
|
28
|
+
throw new SigningConfigError("DAYHAND_JWT_ISSUER, DAYHAND_JWT_PRIVATE_JWK and DAYHAND_JWKS must all be set");
|
|
29
|
+
}
|
|
30
|
+
let privateJwk: JsonWebKey & { kid?: string };
|
|
31
|
+
try {
|
|
32
|
+
privateJwk = JSON.parse(rawKey);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new SigningConfigError("DAYHAND_JWT_PRIVATE_JWK is not JSON");
|
|
35
|
+
}
|
|
36
|
+
const { kty, crv, d, x, y, kid } = privateJwk;
|
|
37
|
+
if (kty !== "EC" || crv !== "P-256" || !d || !x || !y || !kid) {
|
|
38
|
+
throw new SigningConfigError("DAYHAND_JWT_PRIVATE_JWK must be a P-256 EC private JWK with a kid");
|
|
39
|
+
}
|
|
40
|
+
const published = decodeJwks(jwks).keys.find((k) => k.kid === kid);
|
|
41
|
+
if (!published || published.x !== x || published.y !== y) {
|
|
42
|
+
throw new SigningConfigError("DAYHAND_JWT_PRIVATE_JWK does not match the key with its kid in DAYHAND_JWKS");
|
|
43
|
+
}
|
|
44
|
+
return { issuer, privateJwk: { ...privateJwk, kid } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The JWT subject for a Dayhand user id `<issuer>|<subject>`; null for any other user id. */
|
|
48
|
+
export function appSubject(userId: string, issuer: string): string | null {
|
|
49
|
+
const prefix = `${issuer}|`;
|
|
50
|
+
return userId.startsWith(prefix) && userId.length > prefix.length ? userId.slice(prefix.length) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const encodeJson = (value: unknown) => bytesToBase64url(new TextEncoder().encode(JSON.stringify(value)));
|
|
54
|
+
|
|
55
|
+
export async function signAppToken(config: SigningConfig, subject: string, nowMs = Date.now()) {
|
|
56
|
+
const { kty, crv, d, x, y } = config.privateJwk;
|
|
57
|
+
const key = await crypto.subtle.importKey("jwk", { kty, crv, d, x, y }, ALGORITHM, false, ["sign"]);
|
|
58
|
+
const iat = Math.floor(nowMs / 1000);
|
|
59
|
+
const exp = iat + APP_TOKEN_TTL_SECONDS;
|
|
60
|
+
const header = encodeJson({ alg: "ES256", typ: "JWT", kid: config.privateJwk.kid });
|
|
61
|
+
const payload = encodeJson({ iss: config.issuer, sub: subject, aud: AUDIENCE, iat, exp });
|
|
62
|
+
const signature = await crypto.subtle.sign(
|
|
63
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
64
|
+
key,
|
|
65
|
+
new TextEncoder().encode(`${header}.${payload}`),
|
|
66
|
+
);
|
|
67
|
+
return { token: `${header}.${payload}.${bytesToBase64url(new Uint8Array(signature))}`, expiresAt: exp * 1000 };
|
|
68
|
+
}
|