Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,12 @@ with a key.
- response: `{ "lists": [...] }`
- `GET /api/v1/lists/items?listId=<id>` — get a list and its items (`items:read`).
- response: `{ "list": {...} | null, "items": [...] }`
- `GET /api/v1/action-records?itemId=<id>` — signed action records for an item (`items:read`).
- `GET /api/v1/action-records?listId=<id>` — a list's own records, e.g. created/renamed (`lists:read`).
- response: `{ "records": [...] }`, newest first, at most 50. Each record carries the exact
signed `payload`, its `digest`, `status` (`pending` | `signed` | `failed` | `unsigned`) and,
once signed, `signature`, `publicKeyMultibase` and `verificationMethod`. See
[action records](docs/action-records.md) for the payload and how to verify it independently.

### Writes (JWT or `X-API-Key`)

Expand All @@ -247,9 +253,11 @@ The existing mutation endpoints now also accept an `X-API-Key`. All require the
- `POST /api/items/add`, `POST /api/items/check`, `POST /api/items/uncheck`,
`POST /api/items/remove`, `POST /api/items/reorder`

Item writes made via an API key are attributed to the key's owner DID (or its
`agentDid`, if set). The item-authorship credential remains an unsigned
placeholder — cryptographic signing is out of scope for now.
Writes made via an API key are authorized by the key's owner account. Creating,
completing and reopening items and creating or renaming lists each write an
[action record](docs/action-records.md) naming that account and the API key row
used; it is signed asynchronously with the account's Turnkey-held key. A
caller-supplied DID or a key's `agentDid` never establishes who acted.

### Scopes

Expand Down
8 changes: 8 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* @module
*/

import type * as actionRecordSigning from "../actionRecordSigning.js";
import type * as actionRecords from "../actionRecords.js";
import type * as activity from "../activity.js";
import type * as activityHttp from "../activityHttp.js";
import type * as actorSession from "../actorSession.js";
Expand Down Expand Up @@ -44,6 +46,8 @@ import type * as invitations from "../invitations.js";
import type * as invitationMail from "../invitationMail.js";
import type * as items from "../items.js";
import type * as itemsHttp from "../itemsHttp.js";
import type * as lib_actionRecordSigner from "../lib/actionRecordSigner.js";
import type * as lib_actionRecords from "../lib/actionRecords.js";
import type * as lib_actor from "../lib/actor.js";
import type * as lib_analytics from "../lib/analytics.js";
import type * as lib_apiKeyHelpers from "../lib/apiKeyHelpers.js";
Expand Down Expand Up @@ -112,6 +116,8 @@ import type {
} from "convex/server";

declare const fullApi: ApiFromModules<{
actionRecordSigning: typeof actionRecordSigning;
actionRecords: typeof actionRecords;
activity: typeof activity;
activityHttp: typeof activityHttp;
actorSession: typeof actorSession;
Expand Down Expand Up @@ -148,6 +154,8 @@ declare const fullApi: ApiFromModules<{
invitationMail: typeof invitationMail;
items: typeof items;
itemsHttp: typeof itemsHttp;
"lib/actionRecordSigner": typeof lib_actionRecordSigner;
"lib/actionRecords": typeof lib_actionRecords;
"lib/actor": typeof lib_actor;
"lib/analytics": typeof lib_analytics;
"lib/apiKeyHelpers": typeof lib_apiKeyHelpers;
Expand Down
18 changes: 18 additions & 0 deletions convex/actionRecordSigning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use node";

/**
* Asynchronous signer for action records. Scheduled by recordActions (and by
* retries); signs each pending record with its owner's Turnkey sub-org key,
* server-initiated like didCreation. All logic lives in lib/actionRecordSigner.
*/
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { signActionRecords } from "./lib/actionRecordSigner";
import { turnkeySigningKey } from "./turnkeyHelpers";

export const sign = internalAction({
args: { recordIds: v.array(v.id("actionRecords")) },
handler: async (ctx, args): Promise<void> => {
await signActionRecords(ctx, args.recordIds, subOrgId => turnkeySigningKey(subOrgId));
},
});
166 changes: 166 additions & 0 deletions convex/actionRecords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Signed action records (#237): reads for provenance/verification and the
* database half of asynchronous signing. The Turnkey half is the "use node"
* action in actionRecordSigning.ts. See docs/action-records.md.
*/
import { v } from "convex/values";
import { internalMutation, internalQuery } from "./_generated/server";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { actorQuery } from "./lib/authenticated";

// One initial try plus three retries, then the record is marked failed.
export const MAX_SIGNING_ATTEMPTS = 4;
const RETRY_DELAYS_MS = [60_000, 5 * 60_000, 25 * 60_000];
// Longer than the largest backoff, so the sweep never races a scheduled retry.
const STALE_PENDING_MS = 60 * 60_000;
const MAX_RECORDS_PER_READ = 50;

/** Everything shared/actionRecord.ts verifyActionRecord needs, and nothing internal. */
function evidence(record: Doc<"actionRecords">) {
return {
_id: record._id,
payload: record.payload,
digest: record.digest,
status: record.status,
listId: record.listId,
itemId: record.itemId,
ownerUserId: record.ownerUserId,
signature: record.signature,
verificationMethod: record.verificationMethod,
publicKeyMultibase: record.publicKeyMultibase,
signedAt: record.signedAt,
error: record.error,
};
}

/** Newest action records for one item. Same read access as the item itself. */
export const { public: getItemActionRecords, internal: getItemActionRecordsInternal } = actorQuery({
resources: args => ({ items: [args.itemId] }),
scope: "items:read",
args: { itemId: v.id("items") },
handler: async (ctx, args) => (await ctx.db.query("actionRecords")
.withIndex("by_item", q => q.eq("itemId", args.itemId))
.order("desc").take(MAX_RECORDS_PER_READ)).map(evidence),
});

/** Newest list-level records (created, renamed). Item records are read per item. */
export const { public: getListActionRecords, internal: getListActionRecordsInternal } = actorQuery({
resources: args => ({ lists: [args.listId] }),
scope: "lists:read",
args: { listId: v.id("lists") },
handler: async (ctx, args) => (await ctx.db.query("actionRecords")
.withIndex("by_list", q => q.eq("listId", args.listId).eq("itemId", undefined))
.order("desc").take(MAX_RECORDS_PER_READ)).map(evidence),
});

/** Pending records with their owner's current signing identity. Already-settled
* or deleted records are skipped, which is what makes a repeated signer run a no-op. */
export const loadForSigning = internalQuery({
args: { recordIds: v.array(v.id("actionRecords")) },
handler: async (ctx, args) => {
const owners = new Map<Id<"users">, Doc<"users"> | null>();
const pending = [];
for (const recordId of new Set(args.recordIds)) {
const record = await ctx.db.get(recordId);
if (record?.status !== "pending") continue;
if (!owners.has(record.ownerUserId)) owners.set(record.ownerUserId, await ctx.db.get(record.ownerUserId));
const owner = owners.get(record.ownerUserId);
pending.push({
recordId, payload: record.payload, digest: record.digest, ownerUserId: record.ownerUserId,
ownerDid: owner?.did, turnkeySubOrgId: owner?.turnkeySubOrgId,
});
}
return pending;
},
});

const recordId = v.id("actionRecords");
const outcome = v.union(
v.object({
kind: v.literal("signed"), recordId, digest: v.string(), signature: v.string(),
publicKeyMultibase: v.string(), verificationMethod: v.string(),
}),
v.object({ kind: v.literal("unsigned"), recordId }),
// `reason` is a fixed non-secret code, never a provider error message.
v.object({ kind: v.literal("failed"), recordId, reason: v.string(), retry: v.boolean() }),
);

/** Applies signer results. Only pending records change, so duplicate or late
* deliveries cannot replace a stored signature. Retries are scheduled here so
* the attempt count and its retry commit together. */
export const settle = internalMutation({
args: { outcomes: v.array(outcome) },
handler: async (ctx, args) => {
const now = Date.now();
const retries = new Map<number, Id<"actionRecords">[]>();
for (const result of args.outcomes) {
const record = await ctx.db.get(result.recordId);
if (record?.status !== "pending") continue;
const attempts = record.attempts + 1;
if (result.kind === "unsigned") {
await ctx.db.patch(record._id, { status: "unsigned" });
} else if (result.kind === "signed" && result.digest === record.digest) {
await ctx.db.patch(record._id, {
status: "signed", signature: result.signature, publicKeyMultibase: result.publicKeyMultibase,
verificationMethod: result.verificationMethod, signedAt: now, attempts, lastAttemptAt: now,
});
} else if (result.kind === "failed" && result.retry && attempts < MAX_SIGNING_ATTEMPTS) {
await ctx.db.patch(record._id, { attempts, lastAttemptAt: now });
const delay = RETRY_DELAYS_MS[attempts - 1];
retries.set(delay, [...(retries.get(delay) ?? []), record._id]);
} else {
await ctx.db.patch(record._id, {
status: "failed", attempts, lastAttemptAt: now,
error: result.kind === "failed" ? result.reason : "payload_integrity",
});
}
}
for (const [delay, recordIds] of retries) {
await ctx.scheduler.runAfter(delay, internal.actionRecordSigning.sign, { recordIds });
}
},
});

/** Scheduled actions run at most once. This cron re-queues records whose signer
* run never reported back, within the same attempt bound. */
export const sweepStalePending = internalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
const pending = await ctx.db.query("actionRecords")
.withIndex("by_status", q => q.eq("status", "pending")).take(200);
const recordIds: Id<"actionRecords">[] = [];
for (const record of pending) {
if (now - (record.lastAttemptAt ?? record._creationTime) <= STALE_PENDING_MS) continue;
const attempts = record.attempts + 1;
if (attempts >= MAX_SIGNING_ATTEMPTS) {
await ctx.db.patch(record._id, { status: "failed", error: "signer_unresponsive", attempts, lastAttemptAt: now });
} else {
await ctx.db.patch(record._id, { attempts, lastAttemptAt: now });
recordIds.push(record._id);
}
}
if (recordIds.length > 0) {
await ctx.scheduler.runAfter(0, internal.actionRecordSigning.sign, { recordIds });
}
},
});

/** Operator recovery when an outage outlasted the retry window. */
export const requeueFailed = internalMutation({
args: { recordIds: v.array(v.id("actionRecords")) },
handler: async (ctx, args) => {
const recordIds: Id<"actionRecords">[] = [];
for (const id of new Set(args.recordIds)) {
const record = await ctx.db.get(id);
if (record?.status !== "failed") continue;
await ctx.db.patch(id, { status: "pending", attempts: 0, error: undefined, lastAttemptAt: undefined });
recordIds.push(id);
}
if (recordIds.length > 0) {
await ctx.scheduler.runAfter(0, internal.actionRecordSigning.sign, { recordIds });
}
return recordIds.length;
},
});
38 changes: 38 additions & 0 deletions convex/agentReadHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,41 @@ export const getListWithItems = httpAction(async (ctx, request) => {
);
}
});

/**
* GET /api/v1/action-records?itemId=<id> (requires "items:read")
* GET /api/v1/action-records?listId=<id> (requires "lists:read"; list-level records)
*
* Signed action records in the shape shared/actionRecord.ts verifyActionRecord
* takes, so a caller can verify them without trusting this server.
* Response: { "records": [...] }
*/
export const getActionRecords = httpAction(async (ctx, request) => {
try {
const params = new URL(request.url).searchParams;
const itemId = params.get("itemId");
const listId = params.get("listId");
if (!itemId === !listId) {
return errorResponse(request, "Provide exactly one of itemId or listId");
}

const credentials = await authenticatedRequest(ctx, request);
const records = itemId
? await ctx.runQuery(internal.actionRecords.getItemActionRecordsInternal, {
itemId: itemId as Id<"items">,
...credentials,
})
: await ctx.runQuery(internal.actionRecords.getListActionRecordsInternal, {
listId: listId as Id<"lists">,
...credentials,
});
return jsonResponse(request, { records });
} catch (error) {
console.error("[agentReadHttp] getActionRecords error:", error);
return handlerErrorResponse(
request,
error,
"Failed to get action records"
);
}
});
6 changes: 6 additions & 0 deletions convex/crons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,10 @@ crons.interval(
internal.siteActions.pollPendingCustomHostnames
);

crons.interval(
"re-queue stale pending action record signatures",
{ hours: 1 },
internal.actionRecords.sweepStalePending
);

export default crons;
4 changes: 3 additions & 1 deletion convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { getListActivity as getListActivityHttp } from "./activityHttp";
import { stripeWebhook, createCheckout, createPortal, getSubscription } from "./billingHttp";
import { resolveSiteHost, resolveSiteAsset } from "./sitesHttp";
import { createApiKey, listApiKeys, revokeApiKey } from "./apiKeysHttp";
import { getLists, getListWithItems } from "./agentReadHttp";
import { getActionRecords, getLists, getListWithItems } from "./agentReadHttp";
const RATE_LIMITS = {
initiate: { windowMs: 60000, maxAttempts: 5 },
verify: { windowMs: 60000, maxAttempts: 5 },
Expand Down Expand Up @@ -442,6 +442,8 @@ http.route({ path: "/api/v1/lists", method: "GET", handler: getLists });
http.route({ path: "/api/v1/lists", method: "OPTIONS", handler: corsHandler });
http.route({ path: "/api/v1/lists/items", method: "GET", handler: getListWithItems });
http.route({ path: "/api/v1/lists/items", method: "OPTIONS", handler: corsHandler });
http.route({ path: "/api/v1/action-records", method: "GET", handler: getActionRecords });
http.route({ path: "/api/v1/action-records", method: "OPTIONS", handler: corsHandler });

// ============================================================================
// Health check endpoint (public, no auth)
Expand Down
Loading
Loading