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.
Files changed (191) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +100 -2
  3. package/backend/convex/_generated/api.d.ts +181 -0
  4. package/backend/convex/_generated/api.js +23 -0
  5. package/backend/convex/_generated/dataModel.d.ts +60 -0
  6. package/backend/convex/_generated/server.d.ts +162 -0
  7. package/backend/convex/_generated/server.js +101 -0
  8. package/backend/convex/apiKeys.ts +171 -0
  9. package/backend/convex/app.ts +197 -0
  10. package/backend/convex/auth.config.ts +28 -0
  11. package/backend/convex/calendar.ts +61 -0
  12. package/backend/convex/calendarFeeds.ts +105 -0
  13. package/backend/convex/callLimits.ts +67 -0
  14. package/backend/convex/connections.ts +28 -0
  15. package/backend/convex/crons.ts +18 -0
  16. package/backend/convex/devices.ts +224 -0
  17. package/backend/convex/http.ts +56 -0
  18. package/backend/convex/lib/activity.ts +155 -0
  19. package/backend/convex/lib/agentControls.ts +146 -0
  20. package/backend/convex/lib/agentWork.ts +250 -0
  21. package/backend/convex/lib/appToken.ts +68 -0
  22. package/backend/convex/lib/approvals.ts +311 -0
  23. package/backend/convex/lib/args.ts +175 -0
  24. package/backend/convex/lib/backlogMd.ts +309 -0
  25. package/backend/convex/lib/backlogOps.ts +361 -0
  26. package/backend/convex/lib/brief.ts +132 -0
  27. package/backend/convex/lib/cimd.ts +57 -0
  28. package/backend/convex/lib/core.ts +380 -0
  29. package/backend/convex/lib/dates.ts +114 -0
  30. package/backend/convex/lib/env.ts +19 -0
  31. package/backend/convex/lib/exportPlan.ts +98 -0
  32. package/backend/convex/lib/findTasks.ts +119 -0
  33. package/backend/convex/lib/frontmatter.ts +165 -0
  34. package/backend/convex/lib/guide.ts +286 -0
  35. package/backend/convex/lib/health.ts +115 -0
  36. package/backend/convex/lib/http.ts +27 -0
  37. package/backend/convex/lib/ical.ts +243 -0
  38. package/backend/convex/lib/importFormats.ts +429 -0
  39. package/backend/convex/lib/importOps.ts +145 -0
  40. package/backend/convex/lib/jsonSchema.ts +50 -0
  41. package/backend/convex/lib/limits.ts +42 -0
  42. package/backend/convex/lib/mcpContent.ts +220 -0
  43. package/backend/convex/lib/mcpProtocol.ts +128 -0
  44. package/backend/convex/lib/mcpTools.ts +505 -0
  45. package/backend/convex/lib/oauthConfig.ts +59 -0
  46. package/backend/convex/lib/oauthPage.ts +125 -0
  47. package/backend/convex/lib/oauthRules.ts +159 -0
  48. package/backend/convex/lib/openapi.ts +230 -0
  49. package/backend/convex/lib/outputSchemas.ts +2901 -0
  50. package/backend/convex/lib/problem.ts +69 -0
  51. package/backend/convex/lib/proposals.ts +172 -0
  52. package/backend/convex/lib/repeat.ts +185 -0
  53. package/backend/convex/lib/restRoutes.ts +294 -0
  54. package/backend/convex/lib/rrule.ts +171 -0
  55. package/backend/convex/lib/secrets.ts +135 -0
  56. package/backend/convex/lib/settingsOps.ts +47 -0
  57. package/backend/convex/lib/taskOps.ts +304 -0
  58. package/backend/convex/lib/toolResults.ts +50 -0
  59. package/backend/convex/lib/validators.ts +143 -0
  60. package/backend/convex/lib/views.ts +145 -0
  61. package/backend/convex/lib/vtimezone.ts +151 -0
  62. package/backend/convex/lib/webhookEvents.ts +160 -0
  63. package/backend/convex/lib/webhookSign.ts +30 -0
  64. package/backend/convex/lib/webhookTarget.ts +75 -0
  65. package/backend/convex/mcp.ts +405 -0
  66. package/backend/convex/migrations.ts +91 -0
  67. package/backend/convex/oauth.ts +388 -0
  68. package/backend/convex/oauthHttp.ts +381 -0
  69. package/backend/convex/oauthOwner.ts +173 -0
  70. package/backend/convex/ops.ts +205 -0
  71. package/backend/convex/pairing.ts +178 -0
  72. package/backend/convex/rest.ts +254 -0
  73. package/backend/convex/restAccount.ts +56 -0
  74. package/backend/convex/restImport.ts +73 -0
  75. package/backend/convex/schema.ts +435 -0
  76. package/backend/convex/tsconfig.json +18 -0
  77. package/backend/convex/webhooks.ts +339 -0
  78. package/backend/package-lock.json +3068 -0
  79. package/backend/package.json +33 -0
  80. package/backend/scripts/app-token.mjs +79 -0
  81. package/backend/scripts/fetch-local-backend.mjs +72 -0
  82. package/backend/scripts/gen-output-schemas.mjs +60 -0
  83. package/backend/scripts/jwt.mjs +67 -0
  84. package/backend/scripts/local-backend-lib.mjs +123 -0
  85. package/backend/scripts/local-backend.mjs +81 -0
  86. package/backend/scripts/setup-convex.mjs +158 -0
  87. package/backend/scripts/setup-lib.mjs +338 -0
  88. package/backend/scripts/setup-output.mjs +108 -0
  89. package/backend/scripts/setup.mjs +344 -0
  90. package/backend/scripts/signing-key.mjs +61 -0
  91. package/backend/tsconfig.json +16 -0
  92. package/dist/backlog/folder.js +114 -0
  93. package/dist/bin/dayhand.js +10 -0
  94. package/dist/bin/hlm.js +12 -0
  95. package/dist/completion/cache.js +54 -0
  96. package/dist/completion/engine.js +187 -0
  97. package/dist/completion/scripts.js +82 -0
  98. package/dist/config/aliases.js +25 -0
  99. package/dist/config/env.js +68 -0
  100. package/dist/config/file.js +146 -0
  101. package/dist/config/paths.js +58 -0
  102. package/dist/config/resolve.js +53 -0
  103. package/dist/config/url.js +26 -0
  104. package/dist/core/editor.js +37 -0
  105. package/dist/core/exit.js +59 -0
  106. package/dist/core/io.js +14 -0
  107. package/dist/core/output.js +39 -0
  108. package/dist/core/prompt.js +55 -0
  109. package/dist/core/version.js +15 -0
  110. package/dist/core/words.js +36 -0
  111. package/dist/credentials/file.js +65 -0
  112. package/dist/credentials/index.js +29 -0
  113. package/dist/credentials/keychain.js +44 -0
  114. package/dist/credentials/store.js +39 -0
  115. package/dist/docs.js +116 -0
  116. package/dist/fields.js +33 -0
  117. package/dist/globals.js +16 -0
  118. package/dist/guide.js +36 -0
  119. package/dist/help.js +56 -0
  120. package/dist/i18n/dates.js +84 -0
  121. package/dist/i18n/de.js +240 -0
  122. package/dist/i18n/en.js +243 -0
  123. package/dist/i18n/es.js +240 -0
  124. package/dist/i18n/fr.js +240 -0
  125. package/dist/i18n/index.js +67 -0
  126. package/dist/i18n/ja.js +240 -0
  127. package/dist/i18n/pl.js +245 -0
  128. package/dist/i18n/pt-BR.js +240 -0
  129. package/dist/i18n/zh-Hans.js +240 -0
  130. package/dist/main.js +180 -0
  131. package/dist/man.js +56 -0
  132. package/dist/registry/agents.js +67 -0
  133. package/dist/registry/auth.js +168 -0
  134. package/dist/registry/backlog.js +106 -0
  135. package/dist/registry/calendar.js +103 -0
  136. package/dist/registry/dates.js +33 -0
  137. package/dist/registry/filters.js +98 -0
  138. package/dist/registry/index.js +24 -0
  139. package/dist/registry/integrations.js +245 -0
  140. package/dist/registry/local.js +89 -0
  141. package/dist/registry/model.js +104 -0
  142. package/dist/registry/ops.js +47 -0
  143. package/dist/registry/owner.js +349 -0
  144. package/dist/registry/packaged-backend.js +59 -0
  145. package/dist/registry/parse.js +129 -0
  146. package/dist/registry/server.js +252 -0
  147. package/dist/registry/system.js +220 -0
  148. package/dist/registry/tasks.js +512 -0
  149. package/dist/registry/types.js +1 -0
  150. package/dist/registry/ui.js +51 -0
  151. package/dist/registry/work.js +207 -0
  152. package/dist/render/format.js +146 -0
  153. package/dist/render/model.js +29 -0
  154. package/dist/render/style.js +24 -0
  155. package/dist/render/table.js +46 -0
  156. package/dist/render/views.js +256 -0
  157. package/dist/run.js +133 -0
  158. package/dist/setup.js +26 -0
  159. package/dist/skill.js +83 -0
  160. package/dist/topics.js +121 -0
  161. package/dist/transport/client.js +196 -0
  162. package/dist/ui/agents.js +12 -0
  163. package/dist/ui/api.js +99 -0
  164. package/dist/ui/app.js +62 -0
  165. package/dist/ui/budget.js +42 -0
  166. package/dist/ui/controller.js +368 -0
  167. package/dist/ui/effects.js +166 -0
  168. package/dist/ui/frame.js +206 -0
  169. package/dist/ui/handle.js +271 -0
  170. package/dist/ui/health.js +22 -0
  171. package/dist/ui/keys.js +120 -0
  172. package/dist/ui/line.js +56 -0
  173. package/dist/ui/lists.js +58 -0
  174. package/dist/ui/model.js +25 -0
  175. package/dist/ui/quickadd.js +66 -0
  176. package/dist/ui/rows.js +68 -0
  177. package/dist/ui/run.js +54 -0
  178. package/dist/ui/screens/agents.js +65 -0
  179. package/dist/ui/screens/board.js +41 -0
  180. package/dist/ui/screens/detail.js +142 -0
  181. package/dist/ui/screens/overlay.js +58 -0
  182. package/dist/ui/screens/screen.js +3 -0
  183. package/dist/ui/screens/today.js +60 -0
  184. package/dist/ui/screens/week.js +76 -0
  185. package/dist/ui/state.js +128 -0
  186. package/dist/ui/text.js +70 -0
  187. package/dist/when/calendar.js +95 -0
  188. package/dist/when/parse.js +314 -0
  189. package/man/dayhand.1 +1163 -0
  190. package/man/dh.1 +1 -0
  191. package/package.json +56 -4
@@ -0,0 +1,61 @@
1
+ // HTTP endpoints of the calendar feed (docs/calendar.md).
2
+ // GET /cal/<token>.ics the plan as iCalendar (the token is the only key)
3
+ // GET /calendar/feed the feed's addresses, or null (personal API key)
4
+ // POST /calendar/feed turn the feed on (personal API key)
5
+ // POST /calendar/feed/rotate a new address; the old one 404 (personal API key)
6
+ // POST /calendar/feed/revoke turn the feed off (personal API key)
7
+ // The token is never logged; a wrong, old or malformed one is a plain 404.
8
+ import { internal } from "./_generated/api";
9
+ import type { CalendarFeed } from "./calendarFeeds";
10
+ import { httpAction } from "./_generated/server";
11
+ import { json } from "./lib/http";
12
+ import { parseCalendarToken } from "./lib/secrets";
13
+ import { accountOwner, keyIdentity } from "./pairing";
14
+
15
+ const FEED_PATH = /^\/cal\/([^/]+)\.ics$/;
16
+ const noStore = { "Cache-Control": "no-store" };
17
+
18
+ const notFound = () => new Response("Not found\n", { status: 404, headers: { "Content-Type": "text/plain; charset=utf-8", ...noStore } });
19
+
20
+ export const feed = httpAction(async (ctx, request) => {
21
+ const match = FEED_PATH.exec(new URL(request.url).pathname);
22
+ const token = match ? parseCalendarToken(match[1]) : null;
23
+ if (!token) return notFound();
24
+ const text = await ctx.runQuery(internal.calendarFeeds.render, { ...token, at: Date.now() });
25
+ if (text === null) return notFound();
26
+ return new Response(text, {
27
+ status: 200,
28
+ headers: {
29
+ "Content-Type": "text/calendar; charset=utf-8",
30
+ "Content-Disposition": 'inline; filename="dayhand.ics"',
31
+ // Calendar apps poll; a short private cache spares the deployment, and no shared cache keeps the plan.
32
+ "Cache-Control": "private, max-age=300",
33
+ "Referrer-Policy": "no-referrer",
34
+ "X-Robots-Tag": "noindex, nofollow",
35
+ },
36
+ });
37
+ });
38
+
39
+ export const getFeed = httpAction(async (ctx, request) => {
40
+ const owner = await accountOwner(ctx, request);
41
+ if (owner instanceof Response) return owner;
42
+ return json({ feed: await ctx.runQuery(internal.calendarFeeds.get, keyIdentity(owner)) }, 200, noStore);
43
+ });
44
+
45
+ const create = (replace: boolean) =>
46
+ httpAction(async (ctx, request) => {
47
+ const owner = await accountOwner(ctx, request);
48
+ if (owner instanceof Response) return owner;
49
+ const created: CalendarFeed = await ctx.runAction(internal.calendarFeeds.create, { ...keyIdentity(owner), replace });
50
+ return json({ feed: created }, 200, noStore);
51
+ });
52
+
53
+ export const enableFeed = create(false);
54
+ export const rotateFeed = create(true);
55
+
56
+ export const revokeFeed = httpAction(async (ctx, request) => {
57
+ const owner = await accountOwner(ctx, request);
58
+ if (owner instanceof Response) return owner;
59
+ return json(await ctx.runMutation(internal.calendarFeeds.revoke, keyIdentity(owner)), 200, noStore);
60
+ });
61
+
@@ -0,0 +1,105 @@
1
+ // The read-only calendar feed: one secret address per user that serves the
2
+ // plan as iCalendar (lib/ical.ts) to Google Calendar, Apple Calendar and
3
+ // anything else that subscribes by URL. A new address replaces the old one,
4
+ // which then answers 404. Tokens are drawn in actions (mutations have a
5
+ // deterministic random source) and never written to logs or to the history.
6
+ // Threat model: docs/calendar.md.
7
+ import { v } from "convex/values";
8
+ import { internal } from "./_generated/api";
9
+ import { internalAction, internalMutation, internalQuery, type MutationCtx, type QueryCtx } from "./_generated/server";
10
+ import { identityOf, loadSettings, recordEvent, type Identity } from "./lib/core";
11
+ import { renderCalendar } from "./lib/ical";
12
+ import { newCalendarToken, timingSafeEqual } from "./lib/secrets";
13
+ import { identityFields as identityArgs } from "./lib/validators";
14
+
15
+ export type CalendarFeed = { url: string; webcalUrl: string; googleCalendarUrl: string; createdAt: number };
16
+
17
+ /** The deployment's HTTP actions URL, which serves /cal/. */
18
+ function siteUrl(): string {
19
+ const url = process.env.CONVEX_SITE_URL;
20
+ if (!url) throw new Error("CONVEX_SITE_URL is not set");
21
+ return url.replace(/\/+$/, "");
22
+ }
23
+
24
+ /** The addresses of a feed: https for copying, webcal:// for Apple Calendar, and Google Calendar's "add by URL" link. */
25
+ export function feedView(row: { selector: string; secret: string; createdAt: number }): CalendarFeed {
26
+ const url = `${siteUrl()}/cal/${row.selector}${row.secret}.ics`;
27
+ const webcalUrl = url.replace(/^https?:\/\//, "webcal://");
28
+ return {
29
+ url,
30
+ webcalUrl,
31
+ googleCalendarUrl: `https://calendar.google.com/calendar/render?cid=${encodeURIComponent(webcalUrl)}`,
32
+ createdAt: row.createdAt,
33
+ };
34
+ }
35
+
36
+ async function feedRow(ctx: QueryCtx, userId: string) {
37
+ return ctx.db
38
+ .query("calendarFeeds")
39
+ .withIndex("by_user", (q) => q.eq("userId", userId))
40
+ .unique();
41
+ }
42
+
43
+ /** The user's feed, or null when it is off. */
44
+ export async function getFeed(ctx: QueryCtx, identity: Identity): Promise<CalendarFeed | null> {
45
+ const row = await feedRow(ctx, identity.userId);
46
+ return row ? feedView(row) : null;
47
+ }
48
+
49
+ export const get = internalQuery({ args: identityArgs, handler: (ctx, input) => getFeed(ctx, input) });
50
+
51
+ /**
52
+ * Turns the feed on and returns it. With `replace`, a new address replaces
53
+ * the current one (which stops working at once); without, an existing feed
54
+ * is returned as it is.
55
+ */
56
+ export const create = internalAction({
57
+ args: { ...identityArgs, replace: v.optional(v.boolean()) },
58
+ handler: async (ctx, input): Promise<CalendarFeed> => {
59
+ const { selector, secret } = newCalendarToken();
60
+ return ctx.runMutation(internal.calendarFeeds.store, { ...input, selector, secret });
61
+ },
62
+ });
63
+
64
+ export const store = internalMutation({
65
+ args: { ...identityArgs, replace: v.optional(v.boolean()), selector: v.string(), secret: v.string() },
66
+ handler: async (ctx, input): Promise<CalendarFeed> => {
67
+ const { userId, replace, selector, secret } = input;
68
+ const current = await feedRow(ctx, userId);
69
+ if (current && !replace) return feedView(current);
70
+ const createdAt = Date.now();
71
+ if (current) await ctx.db.patch(current._id, { selector, secret, createdAt });
72
+ else await ctx.db.insert("calendarFeeds", { userId, selector, secret, createdAt });
73
+ await recordEvent(ctx, identityOf(input), { action: current ? "calendarFeed.rotate" : "calendarFeed.create" });
74
+ return feedView({ selector, secret, createdAt });
75
+ },
76
+ });
77
+
78
+ /** Turns the feed off: its address answers 404 from now on. */
79
+ export async function revokeFeed(ctx: MutationCtx, identity: Identity): Promise<{ revoked: boolean }> {
80
+ const current = await feedRow(ctx, identity.userId);
81
+ if (!current) return { revoked: false };
82
+ await ctx.db.delete(current._id);
83
+ await recordEvent(ctx, identityOf(identity), { action: "calendarFeed.revoke" });
84
+ return { revoked: true };
85
+ }
86
+
87
+ export const revoke = internalMutation({ args: identityArgs, handler: (ctx, input) => revokeFeed(ctx, input) });
88
+
89
+ /** The calendar behind a feed token, or null when no feed has it. */
90
+ export const render = internalQuery({
91
+ args: { selector: v.string(), secret: v.string(), at: v.number() },
92
+ handler: async (ctx, { selector, secret, at }): Promise<string | null> => {
93
+ const row = await ctx.db
94
+ .query("calendarFeeds")
95
+ .withIndex("by_selector", (q) => q.eq("selector", selector))
96
+ .unique();
97
+ if (!row || !timingSafeEqual(row.secret, secret)) return null;
98
+ const settings = await loadSettings(ctx, row.userId);
99
+ const tasks = await ctx.db
100
+ .query("tasks")
101
+ .withIndex("by_user", (q) => q.eq("userId", row.userId))
102
+ .collect();
103
+ return renderCalendar(tasks, { ...settings, at });
104
+ },
105
+ });
@@ -0,0 +1,67 @@
1
+ // Limits on MCP and REST calls per API key or agent connection (OAuth): a fixed
2
+ // one-minute window that starts at the first call, separately for reads and writes. A call over the limit is
3
+ // refused before it runs and says when to try again.
4
+ import { v, type Infer } from "convex/values";
5
+ import type { Id } from "./_generated/dataModel";
6
+ import { internalMutation, type MutationCtx } from "./_generated/server";
7
+
8
+ export const CALL_WINDOW_MS = 60 * 1000;
9
+ export const CALL_LIMITS = { read: 120, write: 60 } as const;
10
+
11
+ const kindValidator = v.union(v.literal("read"), v.literal("write"));
12
+ export type CallKind = Infer<typeof kindValidator>;
13
+
14
+ /** The outcome of a call against the limit, with what the REST API shows in its X-RateLimit-* headers. */
15
+ export type CallAllowance = { limit: number; remaining: number; resetAt: number } & ({ ok: true } | { ok: false; retryAfterMs: number });
16
+
17
+ type Subject = { keyId?: Id<"apiKeys">; connectionId?: Id<"oauthGrants"> };
18
+
19
+ /** The counters of one key or one connection, of one kind or of both. */
20
+ function rowsOf(ctx: MutationCtx, { keyId, connectionId }: Subject, kind?: CallKind) {
21
+ if (keyId) {
22
+ return ctx.db.query("callLimits").withIndex("by_key_kind", (q) => (kind ? q.eq("keyId", keyId).eq("kind", kind) : q.eq("keyId", keyId)));
23
+ }
24
+ return ctx.db
25
+ .query("callLimits")
26
+ .withIndex("by_connection_kind", (q) => (kind ? q.eq("connectionId", connectionId).eq("kind", kind) : q.eq("connectionId", connectionId)));
27
+ }
28
+
29
+ const subjectArgs = { keyId: v.optional(v.id("apiKeys")), connectionId: v.optional(v.id("oauthGrants")) };
30
+
31
+ /** Counts one call of `kind` with the key or connection (exactly one of them); refused calls are not counted. */
32
+ export const consume = internalMutation({
33
+ args: { ...subjectArgs, kind: kindValidator },
34
+ handler: async (ctx, { keyId, connectionId, kind }): Promise<CallAllowance> => {
35
+ if (!keyId === !connectionId) throw new Error("consume needs exactly one of keyId and connectionId");
36
+ const subject = keyId ? { keyId } : { connectionId };
37
+ const now = Date.now();
38
+ const limit = CALL_LIMITS[kind];
39
+ const row = await rowsOf(ctx, subject, kind).unique();
40
+ const fresh = { ok: true as const, limit, remaining: limit - 1, resetAt: now + CALL_WINDOW_MS };
41
+ if (!row) {
42
+ await ctx.db.insert("callLimits", { ...subject, kind, windowStart: now, calls: 1 });
43
+ return fresh;
44
+ }
45
+ if (now - row.windowStart >= CALL_WINDOW_MS) {
46
+ await ctx.db.patch(row._id, { windowStart: now, calls: 1 });
47
+ return fresh;
48
+ }
49
+ const resetAt = row.windowStart + CALL_WINDOW_MS;
50
+ if (row.calls >= limit) return { ok: false, limit, remaining: 0, resetAt, retryAfterMs: resetAt - now };
51
+ await ctx.db.patch(row._id, { calls: row.calls + 1 });
52
+ return { ok: true, limit, remaining: limit - row.calls - 1, resetAt };
53
+ },
54
+ });
55
+
56
+ /**
57
+ * Clears a key's or a connection's counters, e.g. after a runaway agent:
58
+ * `npx convex run callLimits:reset '{"keyId":"…"}'` (or `'{"connectionId":"…"}'`).
59
+ */
60
+ export const reset = internalMutation({
61
+ args: subjectArgs,
62
+ handler: async (ctx, { keyId, connectionId }) => {
63
+ if (!keyId === !connectionId) throw new Error("reset needs exactly one of keyId and connectionId");
64
+ const rows = await rowsOf(ctx, keyId ? { keyId } : { connectionId }).collect();
65
+ for (const row of rows) await ctx.db.delete(row._id);
66
+ },
67
+ });
@@ -0,0 +1,28 @@
1
+ // Agent connections (OAuth, docs/oauth.md) managed with a personal API key:
2
+ // GET /connections list connections (dayhand connection list)
3
+ // POST /connections/revoke revoke one (dayhand connection revoke)
4
+ // Like /devices, these accept only API keys: an OAuth token cannot manage connections.
5
+ import { internal } from "./_generated/api";
6
+ import { httpAction } from "./_generated/server";
7
+ import { errorResponse, json, readJsonBody } from "./lib/http";
8
+ import { accountOwner, domainError, keyIdentity } from "./pairing";
9
+
10
+ export const listConnections = httpAction(async (ctx, request) => {
11
+ const owner = await accountOwner(ctx, request);
12
+ if (owner instanceof Response) return owner;
13
+ return json({ connections: await ctx.runQuery(internal.oauthOwner.list, keyIdentity(owner)) });
14
+ });
15
+
16
+ export const revokeConnection = httpAction(async (ctx, request) => {
17
+ const owner = await accountOwner(ctx, request);
18
+ if (owner instanceof Response) return owner;
19
+ const body = await readJsonBody(request);
20
+ if (!body || typeof body.connectionId !== "string") {
21
+ return errorResponse(400, "invalid_request", "Body must be JSON with a string `connectionId`");
22
+ }
23
+ try {
24
+ return json(await ctx.runMutation(internal.oauthOwner.revoke, { ...keyIdentity(owner), id: body.connectionId }));
25
+ } catch (error) {
26
+ return domainError(error);
27
+ }
28
+ });
@@ -0,0 +1,18 @@
1
+ import { cronJobs } from "convex/server";
2
+ import { internal } from "./_generated/api";
3
+
4
+ const crons = cronJobs();
5
+
6
+ // Empties the trash of tasks deleted more than 30 days ago.
7
+ crons.daily("purge expired trash", { hourUTC: 3, minuteUTC: 30 }, internal.ops.purgeTrash, {});
8
+
9
+ // Removes pairing codes that expired more than a day ago.
10
+ crons.daily("purge expired pairing codes", { hourUTC: 3, minuteUTC: 45 }, internal.devices.purgeExpiredCodes, {});
11
+
12
+ // Removes OAuth requests and tokens that ended more than a day ago, and unused client registrations.
13
+ crons.daily("purge expired oauth records", { hourUTC: 4, minuteUTC: 0 }, internal.oauth.purgeExpired, {});
14
+
15
+ // Drops webhook delivery log entries older than 30 days.
16
+ crons.daily("purge old webhook deliveries", { hourUTC: 4, minuteUTC: 15 }, internal.webhooks.purgeDeliveries, {});
17
+
18
+ export default crons;
@@ -0,0 +1,224 @@
1
+ // Phone pairing: one-time pairing codes, long-lived revocable device tokens
2
+ // and the failed-attempt limit of the pairing endpoint. Codes and tokens are
3
+ // generated in actions; only SHA-256 hashes of their secret parts are stored.
4
+ // The HTTP endpoints are in pairing.ts.
5
+ import { v } from "convex/values";
6
+ import { internal } from "./_generated/api";
7
+ import type { Id } from "./_generated/dataModel";
8
+ import { internalAction, internalMutation, internalQuery, type MutationCtx } from "./_generated/server";
9
+ import { appSubject } from "./lib/appToken";
10
+ import { fail, identityOf, recordEvent } from "./lib/core";
11
+ import { serverEnv } from "./lib/env";
12
+ import { formatPairingCode, newPairingCode, parsePairingCode, sha256Hex, timingSafeEqual } from "./lib/secrets";
13
+ import { identityFields as identityArgs } from "./lib/validators";
14
+
15
+ export const PAIRING_CODE_TTL_MS = 10 * 60 * 1000;
16
+ const MAX_CODE_ATTEMPTS = 5;
17
+ const PAIR_BUCKET = "pair";
18
+ const PAIR_FAILURE_LIMIT = 10;
19
+ const PAIR_FAILURE_WINDOW_MS = 10 * 60 * 1000;
20
+ const CODE_RETENTION_MS = 24 * 60 * 60 * 1000;
21
+ const MAX_NAME = 100;
22
+
23
+ export function checkDeviceName(name: string): string {
24
+ const trimmed = name.trim();
25
+ if (!trimmed || trimmed.length > MAX_NAME) fail("invalid_argument", `deviceName must be 1 to ${MAX_NAME} characters`);
26
+ return trimmed;
27
+ }
28
+
29
+ // ---- Pairing codes ----
30
+
31
+ /** Creates a pairing code for `userId`; the plain code is returned exactly once. `ttlMs` is only for tests. */
32
+ export const createPairingCode = internalAction({
33
+ args: { ...identityArgs, createdBy: v.optional(v.string()), ttlMs: v.optional(v.number()) },
34
+ handler: async (ctx, input): Promise<{ code: string; expiresAt: number }> => {
35
+ const ttlMs = Math.min(Math.max(input.ttlMs ?? PAIRING_CODE_TTL_MS, 1), PAIRING_CODE_TTL_MS);
36
+ // A new selector rarely collides with an active code; draw again if it does.
37
+ for (let attempt = 0; attempt < 5; attempt++) {
38
+ const code = newPairingCode();
39
+ const parsed = parsePairingCode(code)!;
40
+ const expiresAt: number | null = await ctx.runMutation(internal.devices.insertCode, {
41
+ ...identityOf(input),
42
+ createdBy: input.createdBy ?? "admin",
43
+ selector: parsed.selector,
44
+ secretHash: await sha256Hex(parsed.secret),
45
+ ttlMs,
46
+ });
47
+ if (expiresAt !== null) return { code: formatPairingCode(code), expiresAt };
48
+ }
49
+ throw new Error("Could not draw an unused pairing code");
50
+ },
51
+ });
52
+
53
+ export const insertCode = internalMutation({
54
+ args: { ...identityArgs, createdBy: v.string(), selector: v.string(), secretHash: v.string(), ttlMs: v.number() },
55
+ handler: async (ctx, input) => {
56
+ const { userId, createdBy, selector, secretHash, ttlMs } = input;
57
+ const issuer = serverEnv("JWT_ISSUER") ?? "";
58
+ if (!appSubject(userId, issuer)) {
59
+ fail("invalid_argument", `User ${userId} cannot sign in to the app: app users are "${issuer}|<subject>"`);
60
+ }
61
+ const now = Date.now();
62
+ const sameSelector = await ctx.db
63
+ .query("pairingCodes")
64
+ .withIndex("by_selector", (q) => q.eq("selector", selector))
65
+ .collect();
66
+ if (sameSelector.some((c) => c.usedAt === undefined && c.expiresAt > now)) return null;
67
+ const expiresAt = now + ttlMs;
68
+ const id = await ctx.db.insert("pairingCodes", {
69
+ userId,
70
+ selector,
71
+ secretHash,
72
+ createdBy,
73
+ createdAt: now,
74
+ expiresAt,
75
+ failedAttempts: 0,
76
+ });
77
+ await recordEvent(ctx, identityOf(input), { action: "device.pairingCode", after: { codeId: id, createdBy, expiresAt } });
78
+ return expiresAt;
79
+ },
80
+ });
81
+
82
+ // ---- Failed-attempt limit of POST /auth/pair ----
83
+ // One counter for the whole deployment (the endpoint is unauthenticated, so
84
+ // there is no user to count per). Over the limit, codes are not checked at all.
85
+
86
+ async function pairLimitRow(ctx: MutationCtx) {
87
+ return ctx.db
88
+ .query("authLimits")
89
+ .withIndex("by_bucket", (q) => q.eq("bucket", PAIR_BUCKET))
90
+ .unique();
91
+ }
92
+
93
+ async function recordPairFailure(ctx: MutationCtx, now: number) {
94
+ const row = await pairLimitRow(ctx);
95
+ if (!row) {
96
+ await ctx.db.insert("authLimits", { bucket: PAIR_BUCKET, windowStart: now, failures: 1 });
97
+ } else if (now - row.windowStart >= PAIR_FAILURE_WINDOW_MS) {
98
+ await ctx.db.patch(row._id, { windowStart: now, failures: 1 });
99
+ } else {
100
+ await ctx.db.patch(row._id, { failures: row.failures + 1 });
101
+ }
102
+ }
103
+
104
+ /** Clears the failed-attempt counter, e.g. after an attacker locked pairing: `npx convex run devices:resetPairLimit`. */
105
+ export const resetPairLimit = internalMutation({
106
+ args: {},
107
+ handler: async (ctx) => {
108
+ const row = await pairLimitRow(ctx);
109
+ if (row) await ctx.db.delete(row._id);
110
+ },
111
+ });
112
+
113
+ type RedeemResult =
114
+ | { ok: true; deviceId: Id<"devices"> }
115
+ | { ok: false; reason: "invalid" }
116
+ | { ok: false; reason: "rate_limited"; retryAfterMs: number };
117
+
118
+ /**
119
+ * Exchanges a pairing code for a new device, at most once. Failures return
120
+ * instead of throwing so that the failure counters are saved.
121
+ */
122
+ export const redeemCode = internalMutation({
123
+ args: {
124
+ selector: v.string(),
125
+ secretHash: v.string(),
126
+ deviceName: v.string(),
127
+ deviceSelector: v.string(),
128
+ deviceSecretHash: v.string(),
129
+ },
130
+ handler: async (ctx, input): Promise<RedeemResult> => {
131
+ const now = Date.now();
132
+ const limit = await pairLimitRow(ctx);
133
+ if (limit && now - limit.windowStart < PAIR_FAILURE_WINDOW_MS && limit.failures >= PAIR_FAILURE_LIMIT) {
134
+ return { ok: false, reason: "rate_limited", retryAfterMs: limit.windowStart + PAIR_FAILURE_WINDOW_MS - now };
135
+ }
136
+ const code = (
137
+ await ctx.db
138
+ .query("pairingCodes")
139
+ .withIndex("by_selector", (q) => q.eq("selector", input.selector))
140
+ .collect()
141
+ ).find((c) => c.usedAt === undefined && c.expiresAt > now && c.failedAttempts < MAX_CODE_ATTEMPTS);
142
+ if (!code || !timingSafeEqual(code.secretHash, input.secretHash)) {
143
+ if (code) await ctx.db.patch(code._id, { failedAttempts: code.failedAttempts + 1 });
144
+ await recordPairFailure(ctx, now);
145
+ return { ok: false, reason: "invalid" };
146
+ }
147
+ await ctx.db.patch(code._id, { usedAt: now });
148
+ const name = checkDeviceName(input.deviceName);
149
+ const deviceId = await ctx.db.insert("devices", {
150
+ userId: code.userId,
151
+ name,
152
+ selector: input.deviceSelector,
153
+ secretHash: input.deviceSecretHash,
154
+ pairedBy: code.createdBy,
155
+ createdAt: now,
156
+ });
157
+ await recordEvent(ctx, { userId: code.userId, actor: "app" }, { action: "device.pair", after: { deviceId, name } });
158
+ return { ok: true, deviceId };
159
+ },
160
+ });
161
+
162
+ /** Run daily by the cron: removes codes that expired more than a day ago. */
163
+ export const purgeExpiredCodes = internalMutation({
164
+ args: {},
165
+ handler: async (ctx) => {
166
+ const expired = await ctx.db
167
+ .query("pairingCodes")
168
+ .withIndex("by_expiresAt", (q) => q.lt("expiresAt", Date.now() - CODE_RETENTION_MS))
169
+ .take(500);
170
+ for (const code of expired) await ctx.db.delete(code._id);
171
+ return { purged: expired.length };
172
+ },
173
+ });
174
+
175
+ // ---- Devices ----
176
+
177
+ /** Resolves an active device from its token; records the use. Null when unknown, wrong or revoked. */
178
+ export const useToken = internalMutation({
179
+ args: { selector: v.string(), secretHash: v.string() },
180
+ handler: async (ctx, { selector, secretHash }) => {
181
+ const device = await ctx.db
182
+ .query("devices")
183
+ .withIndex("by_selector", (q) => q.eq("selector", selector))
184
+ .unique();
185
+ if (!device || device.revokedAt !== undefined || !timingSafeEqual(device.secretHash, secretHash)) return null;
186
+ await ctx.db.patch(device._id, { lastUsedAt: Date.now() });
187
+ return { userId: device.userId, deviceId: device._id };
188
+ },
189
+ });
190
+
191
+ /** Device metadata for `dayhand devices`; never the hash. */
192
+ export const list = internalQuery({
193
+ args: identityArgs,
194
+ handler: async (ctx, { userId }) => {
195
+ const devices = await ctx.db
196
+ .query("devices")
197
+ .withIndex("by_user", (q) => q.eq("userId", userId))
198
+ .collect();
199
+ return devices.map((d) => ({
200
+ id: d._id,
201
+ name: d.name,
202
+ pairedBy: d.pairedBy,
203
+ createdAt: d.createdAt,
204
+ lastUsedAt: d.lastUsedAt ?? null,
205
+ revokedAt: d.revokedAt ?? null,
206
+ }));
207
+ },
208
+ });
209
+
210
+ /** The device gets no new app tokens; a token it already holds expires within an hour. */
211
+ export const revoke = internalMutation({
212
+ args: { ...identityArgs, deviceId: v.string() },
213
+ handler: async (ctx, input) => {
214
+ const { userId, deviceId } = input;
215
+ const id = ctx.db.normalizeId("devices", deviceId);
216
+ const device = id ? await ctx.db.get(id) : null;
217
+ if (!device || device.userId !== userId) fail("not_found", `Device ${deviceId} not found`);
218
+ if (device.revokedAt !== undefined) return { id: device._id, revokedAt: device.revokedAt };
219
+ const revokedAt = Date.now();
220
+ await ctx.db.patch(device._id, { revokedAt });
221
+ await recordEvent(ctx, identityOf(input), { action: "device.revoke", after: { deviceId: device._id, name: device.name } });
222
+ return { id: device._id, revokedAt };
223
+ },
224
+ });
@@ -0,0 +1,56 @@
1
+ import { httpRouter } from "convex/server";
2
+ import { enableFeed, feed, getFeed, revokeFeed, rotateFeed } from "./calendar";
3
+ import { listConnections, revokeConnection } from "./connections";
4
+ import { handleOther, handlePost } from "./mcp";
5
+ import {
6
+ authorizationServerMetadata,
7
+ authorize,
8
+ authorizeContinue,
9
+ authorizeStatus,
10
+ protectedResourceMetadata,
11
+ register,
12
+ revoke,
13
+ token as oauthToken,
14
+ } from "./oauthHttp";
15
+ import { createCode, listDevices, pair, revokeDevice, token } from "./pairing";
16
+ import { handle as handleRest } from "./rest";
17
+
18
+ const http = httpRouter();
19
+
20
+ http.route({ path: "/mcp", method: "POST", handler: handlePost });
21
+ http.route({ path: "/mcp", method: "GET", handler: handleOther });
22
+ http.route({ path: "/mcp", method: "DELETE", handler: handleOther });
23
+
24
+ http.route({ path: "/auth/pair", method: "POST", handler: pair });
25
+ http.route({ path: "/auth/token", method: "POST", handler: token });
26
+ http.route({ path: "/devices/pairing-code", method: "POST", handler: createCode });
27
+ http.route({ path: "/devices", method: "GET", handler: listDevices });
28
+ http.route({ path: "/devices/revoke", method: "POST", handler: revokeDevice });
29
+
30
+ // OAuth 2.1 for MCP clients such as the Claude apps (docs/oauth.md).
31
+ http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: protectedResourceMetadata });
32
+ http.route({ path: "/.well-known/oauth-protected-resource/mcp", method: "GET", handler: protectedResourceMetadata });
33
+ http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: authorizationServerMetadata });
34
+ http.route({ path: "/oauth/authorize", method: "GET", handler: authorize });
35
+ http.route({ path: "/oauth/authorize/status", method: "POST", handler: authorizeStatus });
36
+ http.route({ path: "/oauth/authorize/continue", method: "POST", handler: authorizeContinue });
37
+ http.route({ path: "/oauth/token", method: "POST", handler: oauthToken });
38
+ http.route({ path: "/oauth/register", method: "POST", handler: register });
39
+ http.route({ path: "/oauth/revoke", method: "POST", handler: revoke });
40
+ http.route({ path: "/connections", method: "GET", handler: listConnections });
41
+ http.route({ path: "/connections/revoke", method: "POST", handler: revokeConnection });
42
+
43
+ // Convex routes only GET, POST, PUT, DELETE, OPTIONS and PATCH, so the feed is
44
+ // a plain iCalendar file, not CalDAV (docs/calendar.md).
45
+ http.route({ pathPrefix: "/cal/", method: "GET", handler: feed });
46
+ http.route({ path: "/calendar/feed", method: "GET", handler: getFeed });
47
+ http.route({ path: "/calendar/feed", method: "POST", handler: enableFeed });
48
+ http.route({ path: "/calendar/feed/rotate", method: "POST", handler: rotateFeed });
49
+ http.route({ path: "/calendar/feed/revoke", method: "POST", handler: revokeFeed });
50
+
51
+ // The REST API for integrations (docs/api.md); rest.ts routes below the prefix.
52
+ for (const method of ["GET", "POST", "PATCH", "DELETE"] as const) {
53
+ http.route({ pathPrefix: "/api/v1/", method, handler: handleRest });
54
+ }
55
+
56
+ export default http;