@poppykit/server 0.3.1 → 0.5.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 (57) hide show
  1. package/API.md +11 -3
  2. package/AUTH.md +120 -95
  3. package/CHANGELOG.md +18 -0
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +13 -10
  6. package/DELIVERY.md +21 -1
  7. package/INTEGRATIONS.md +72 -69
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +14 -17
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +57 -2
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +90 -11
  15. package/dist/auth/authenticator.d.ts +3 -1
  16. package/dist/auth/authenticator.js +34 -29
  17. package/dist/auth/authority.d.ts +8 -2
  18. package/dist/auth/authority.js +7 -2
  19. package/dist/auth/session.d.ts +47 -0
  20. package/dist/auth/session.js +94 -0
  21. package/dist/browser.d.ts +15 -13
  22. package/dist/browser.js +80 -46
  23. package/dist/conversation/service.d.ts +18 -8
  24. package/dist/conversation/service.js +60 -28
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +6 -1
  28. package/dist/index.js +3 -0
  29. package/dist/mcp.d.ts +1 -1
  30. package/dist/mcp.js +8 -4
  31. package/dist/network/discovery.d.ts +51 -22
  32. package/dist/network/discovery.js +159 -69
  33. package/dist/network/redirect.d.ts +3 -0
  34. package/dist/network/redirect.js +38 -0
  35. package/dist/network/tasks.d.ts +23 -0
  36. package/dist/network/tasks.js +45 -0
  37. package/dist/operations.d.ts +115 -0
  38. package/dist/operations.js +212 -0
  39. package/dist/postgres.d.ts +6 -0
  40. package/dist/postgres.js +92 -6
  41. package/dist/protocol/operations.d.ts +51 -0
  42. package/dist/protocol/operations.js +98 -0
  43. package/dist/protocol/session.d.ts +28 -0
  44. package/dist/protocol/session.js +93 -0
  45. package/dist/protocol/sse.d.ts +1 -0
  46. package/dist/protocol/sse.js +1 -1
  47. package/dist/protocol/wire.d.ts +20 -15
  48. package/dist/protocol/wire.js +70 -22
  49. package/dist/reference-auth/index.d.ts +17 -12
  50. package/dist/reference-auth/index.js +252 -386
  51. package/dist/reference-auth/store.d.ts +2 -1
  52. package/dist/sqlite.d.ts +11 -0
  53. package/dist/sqlite.js +104 -6
  54. package/examples/operations.mjs +18 -0
  55. package/examples/quickstart.mjs +10 -6
  56. package/migrations/RETIREMENT.md +77 -0
  57. package/package.json +5 -2
@@ -0,0 +1,115 @@
1
+ import type { AuthorityPolicy, Principal } from './auth/authority.js';
2
+ import type { MetadataResolver } from './network/discovery.js';
3
+ import type { Json } from './protocol/wire.js';
4
+ import type { BusinessOperation, OperationApproval, OperationRequest } from './protocol/operations.js';
5
+ export type BusinessOwner = {
6
+ companyId: string;
7
+ clientId: string;
8
+ userId: string;
9
+ } & ({
10
+ accountId: string;
11
+ } | {
12
+ sessionId: string;
13
+ sessionInstanceId: string;
14
+ sessionGeneration: string;
15
+ });
16
+ export interface OperationRevision {
17
+ operation: BusinessOperation;
18
+ request: OperationRequest;
19
+ }
20
+ export interface BusinessOperationRecord {
21
+ version: 1;
22
+ id: string;
23
+ owner: BusinessOwner;
24
+ scopes: string[];
25
+ revisions: Record<string, OperationRevision>;
26
+ execution?: {
27
+ revision: string;
28
+ key: string;
29
+ claimedAt: number;
30
+ retainUntil: number;
31
+ phase: 'uncertain' | 'completed';
32
+ result?: Json;
33
+ };
34
+ }
35
+ /** Cross-process serializable transaction for one business action. Keep tombstones/claims forever until explicit safe retirement.
36
+ * Local effects MUST enlist in this transaction; roll back records and effects on any throw, including commit failure.
37
+ * External effects MUST NOT run inside it. Implementations reject unknown versions and return detached snapshots.
38
+ */
39
+ export interface BusinessOperationStore {
40
+ transaction<T>(operationId: string, action: (slot: {
41
+ record?: BusinessOperationRecord;
42
+ }) => Promise<T>): Promise<T>;
43
+ }
44
+ export interface OperationInput {
45
+ /** Host-selected business identity, stable across revisions/retries; never take this from an untrusted approval. */
46
+ operationId: string;
47
+ principal: Principal;
48
+ request: OperationRequest;
49
+ scopes: readonly string[];
50
+ approval?: OperationApproval;
51
+ proposal(): {
52
+ summary: string;
53
+ terms?: BusinessOperation['terms'];
54
+ expires_at: string;
55
+ };
56
+ }
57
+ export type OperationOutcome = {
58
+ kind: 'approval_required';
59
+ operation: BusinessOperation;
60
+ } | {
61
+ kind: 'pending';
62
+ retryAfter: number;
63
+ } | {
64
+ kind: 'completed';
65
+ result: Json;
66
+ replay: boolean;
67
+ };
68
+ export interface ExternalOperationEffect {
69
+ /** Host guarantees one durable effect per key, including concurrent callers and retries after process death. */
70
+ execute(input: {
71
+ key: string;
72
+ operation: BusinessOperation;
73
+ }): Promise<Json>;
74
+ /** Never return absent until the old worker cannot later commit outside the same idempotency fence. */
75
+ reconcile(input: {
76
+ key: string;
77
+ operation: BusinessOperation;
78
+ }): Promise<{
79
+ kind: 'pending';
80
+ } | {
81
+ kind: 'completed';
82
+ result: Json;
83
+ } | {
84
+ kind: 'absent';
85
+ }>;
86
+ }
87
+ export declare class BusinessOperationError extends Error {
88
+ readonly code: 'operation_not_found' | 'extension_required';
89
+ constructor(code: 'operation_not_found' | 'extension_required');
90
+ response(): Response;
91
+ }
92
+ export declare function operationPendingResponse(retryAfter?: number): Response;
93
+ export declare function createBusinessOperations(options: {
94
+ companyId: string;
95
+ metadata: MetadataResolver;
96
+ policy: AuthorityPolicy;
97
+ store: BusinessOperationStore;
98
+ now?: () => number;
99
+ retentionMs?: number;
100
+ maxRevisions?: number;
101
+ effectTimeoutMs?: number;
102
+ }): {
103
+ /** No effect: proposes terms or returns an existing execution's state. */
104
+ propose(input: OperationInput): Promise<OperationOutcome>;
105
+ /** Only same-database effects. The host must use this store's coordinator/transaction handle. No network I/O. */
106
+ local(input: OperationInput, effect: (revision: BusinessOperation) => Promise<Json>): Promise<OperationOutcome>;
107
+ external(input: OperationInput, effect: ExternalOperationEffect): Promise<OperationOutcome>;
108
+ /** Explicit host reconciliation only; a lease expiring never releases an execution claim. */
109
+ reconcile(input: OperationInput, effect: ExternalOperationEffect): Promise<OperationOutcome>;
110
+ /** Drop protected result bodies only after retention; retain the action fence and never re-execute it. */
111
+ prune(operationId: string): Promise<void>;
112
+ };
113
+ export declare function operationApiResponse(outcome: OperationOutcome, success?: (result: Json) => Response): Response;
114
+ /** A tool's missing extension is an ordinary tool error, not an OAuth refusal of the whole MCP client. */
115
+ export declare function operationMcpResponse(outcome: OperationOutcome | BusinessOperationError): Response;
@@ -0,0 +1,212 @@
1
+ import { randomBytes, createHash } from 'node:crypto';
2
+ import { AuthorityError } from './auth/authority.js';
3
+ import { canonical, id, validateJson } from './protocol/wire.js';
4
+ import { operationRequiredResponse, operationToolError, operationExpiry, parseBusinessOperation, parseOperationApproval, requestFingerprint, supportsOperations } from './protocol/operations.js';
5
+ export class BusinessOperationError extends Error {
6
+ code;
7
+ constructor(code) {
8
+ super(code);
9
+ this.code = code;
10
+ }
11
+ response() {
12
+ return this.code === 'extension_required'
13
+ ? new Response(null, { status: 403, headers: { 'WWW-Authenticate': 'Bearer error="extension_required", extension="operations"', 'Cache-Control': 'no-store' } })
14
+ : Response.json({ error: this.code }, { status: 404, headers: { 'Cache-Control': 'no-store' } });
15
+ }
16
+ }
17
+ function owner(principal) {
18
+ if (!principal.sessionId || !principal.userId || !principal.sessionInstanceId || !principal.sessionGeneration)
19
+ throw new AuthorityError('invalid_session');
20
+ const base = { companyId: principal.issuer, clientId: principal.clientId, userId: principal.userId };
21
+ if (principal.accountId)
22
+ return { ...base, accountId: principal.accountId };
23
+ if (!principal.sessionInstanceId || !principal.sessionGeneration)
24
+ throw new AuthorityError('invalid_session');
25
+ return { ...base, sessionId: principal.sessionId, sessionInstanceId: principal.sessionInstanceId, sessionGeneration: principal.sessionGeneration };
26
+ }
27
+ function owned(record, principal) {
28
+ const o = record.owner;
29
+ if (o.companyId !== principal.issuer || o.clientId !== principal.clientId || o.userId !== principal.userId)
30
+ throw new BusinessOperationError('operation_not_found');
31
+ if ('accountId' in o) {
32
+ if (!principal.accountId)
33
+ throw new AuthorityError('account_required', { scope: record.scopes.join(' ') });
34
+ if (o.accountId !== principal.accountId)
35
+ throw new BusinessOperationError('operation_not_found');
36
+ }
37
+ else if (o.sessionId !== principal.sessionId || o.sessionInstanceId !== principal.sessionInstanceId || o.sessionGeneration !== principal.sessionGeneration)
38
+ throw new BusinessOperationError('operation_not_found');
39
+ }
40
+ export function operationPendingResponse(retryAfter = 1) {
41
+ return Response.json({ error: 'operation_pending' }, { status: 503, headers: { 'Retry-After': String(retryAfter), 'Cache-Control': 'no-store' } });
42
+ }
43
+ export function createBusinessOperations(options) {
44
+ const effectTimeoutMs = options.effectTimeoutMs ?? 30000;
45
+ if (!Number.isSafeInteger(effectTimeoutMs) || effectTimeoutMs < 1)
46
+ throw new TypeError('effectTimeoutMs');
47
+ const maxRevisions = options.maxRevisions ?? 1000;
48
+ if (!Number.isSafeInteger(maxRevisions) || maxRevisions < 1)
49
+ throw new TypeError('maxRevisions');
50
+ const now = options.now ?? Date.now, retentionMs = options.retentionMs ?? 86400000;
51
+ if (!Number.isSafeInteger(retentionMs) || retentionMs < 0)
52
+ throw new TypeError('operation retention');
53
+ async function gate(input, action) {
54
+ id(input.operationId, 'operation_id');
55
+ requestFingerprint(input.request);
56
+ owner(input.principal); // Every channel requires a verified Poppy Session, including signed-in MCP.
57
+ if (input.principal.issuer !== options.companyId)
58
+ throw new AuthorityError('invalid_session');
59
+ if (!supportsOperations(await options.metadata.resolveClient(input.principal.clientId)))
60
+ throw new BusinessOperationError('extension_required');
61
+ return options.policy.withCurrent(input.principal, input.scopes, () => options.store.transaction(input.operationId, async (slot) => {
62
+ if (slot.record && slot.record.version !== 1)
63
+ throw new Error('unsupported business operation version');
64
+ const record = slot.record ?? { version: 1, id: input.operationId, owner: owner(input.principal), scopes: [...input.scopes], revisions: {} };
65
+ owned(record, input.principal);
66
+ if (canonical(record.scopes.slice().sort()) !== canonical([...input.scopes].sort()))
67
+ throw new TypeError('operation scope contract changed');
68
+ slot.record = record;
69
+ return action(record);
70
+ }));
71
+ }
72
+ function inspect(record, input) {
73
+ const approval = input.approval && parseOperationApproval(input.approval);
74
+ // Ownership and current authority precede receipt replay, even for expired revisions.
75
+ if (record.execution) {
76
+ if (record.execution.phase === 'completed' && record.execution.result !== undefined)
77
+ return { kind: 'completed', result: structuredClone(record.execution.result), replay: true };
78
+ if (record.execution.phase === 'uncertain')
79
+ return { kind: 'pending', retryAfter: 1 };
80
+ }
81
+ const revision = !record.execution && approval?.operation_id === record.id ? (Object.hasOwn(record.revisions, approval.revision) ? record.revisions[approval.revision] : undefined) : undefined;
82
+ if (revision && operationExpiry(revision.operation.expires_at) > now() && requestFingerprint(revision.request) === requestFingerprint(input.request))
83
+ return revision;
84
+ const proposed = input.proposal();
85
+ const operation = parseBusinessOperation({ ...proposed, operation_id: record.id, revision: randomBytes(24).toString('base64url') });
86
+ if (operationExpiry(operation.expires_at) <= now())
87
+ throw new TypeError('proposal already expired');
88
+ // Completed/uncertain actions are never reopened, even after receipt cleanup or a new revision.
89
+ if (record.execution)
90
+ return { kind: 'approval_required', operation };
91
+ for (const [key, value] of Object.entries(record.revisions))
92
+ if (operationExpiry(value.operation.expires_at) <= now())
93
+ delete record.revisions[key];
94
+ if (Object.keys(record.revisions).length >= maxRevisions)
95
+ throw new AuthorityError('rate_limited', { retryAfter: 60 });
96
+ record.revisions[operation.revision] = { operation, request: structuredClone(input.request) };
97
+ return { kind: 'approval_required', operation };
98
+ }
99
+ function claim(record, revision) {
100
+ record.execution = { revision: revision.operation.revision, key: createHash('sha256').update(canonical([options.companyId, record.id])).digest('base64url'), claimedAt: now(),
101
+ retainUntil: Math.max(operationExpiry(revision.operation.expires_at), now() + retentionMs), phase: 'uncertain' };
102
+ }
103
+ function complete(record, result, input) {
104
+ if (record.execution?.phase === 'completed')
105
+ return inspect(record, input);
106
+ validateJson(result);
107
+ record.execution.phase = 'completed';
108
+ record.execution.result = structuredClone(result);
109
+ return { kind: 'completed', result: structuredClone(result), replay: false };
110
+ }
111
+ async function externalResult(action) {
112
+ let timer;
113
+ try {
114
+ return await Promise.race([
115
+ Promise.resolve().then(action).then(value => ({ ok: true, value }), () => ({ ok: false })),
116
+ new Promise(resolve => { timer = setTimeout(() => resolve({ ok: false }), effectTimeoutMs); }),
117
+ ]);
118
+ }
119
+ finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+ return {
124
+ /** No effect: proposes terms or returns an existing execution's state. */
125
+ propose(input) { return gate({ ...input, approval: undefined }, async (record) => inspect(record, { ...input, approval: undefined })); },
126
+ /** Only same-database effects. The host must use this store's coordinator/transaction handle. No network I/O. */
127
+ local(input, effect) {
128
+ return gate(input, async (record) => {
129
+ const found = inspect(record, input);
130
+ if ('kind' in found)
131
+ return found;
132
+ claim(record, found);
133
+ const result = await effect(structuredClone(found.operation));
134
+ return complete(record, result, input);
135
+ });
136
+ },
137
+ async external(input, effect) {
138
+ const selected = await gate(input, async (record) => {
139
+ const found = inspect(record, input);
140
+ if ('kind' in found)
141
+ return { outcome: found };
142
+ claim(record, found);
143
+ return { key: record.execution.key, operation: structuredClone(found.operation) };
144
+ });
145
+ if ('outcome' in selected)
146
+ return selected.outcome;
147
+ await options.policy.withCurrent(input.principal, input.scopes, async () => { });
148
+ const attempted = await externalResult(async () => {
149
+ const result = await effect.execute({ key: selected.key, operation: selected.operation });
150
+ validateJson(result);
151
+ return result;
152
+ });
153
+ if (!attempted.ok)
154
+ return { kind: 'pending', retryAfter: 1 };
155
+ return gate(input, async (record) => complete(record, attempted.value, input));
156
+ },
157
+ /** Explicit host reconciliation only; a lease expiring never releases an execution claim. */
158
+ async reconcile(input, effect) {
159
+ const selected = await gate(input, async (record) => {
160
+ if (!record.execution)
161
+ throw new BusinessOperationError('operation_not_found');
162
+ if (record.execution.phase === 'completed') {
163
+ if (record.execution.result === undefined)
164
+ return { outcome: inspect(record, input) };
165
+ return { outcome: { kind: 'completed', result: record.execution.result, replay: true } };
166
+ }
167
+ return { key: record.execution.key, operation: structuredClone(record.revisions[record.execution.revision].operation) };
168
+ });
169
+ if ('outcome' in selected)
170
+ return selected.outcome;
171
+ const checked = await externalResult(() => effect.reconcile({ key: selected.key, operation: selected.operation }));
172
+ if (!checked.ok)
173
+ return { kind: 'pending', retryAfter: 1 };
174
+ let resolved = checked.value;
175
+ if (resolved.kind === 'pending')
176
+ return { kind: 'pending', retryAfter: 1 };
177
+ if (resolved.kind === 'absent') {
178
+ // Reuse the durable downstream key: a paused original worker still cannot cause a second effect.
179
+ await options.policy.withCurrent(input.principal, input.scopes, async () => { });
180
+ const attempted = await externalResult(() => effect.execute({ key: selected.key, operation: selected.operation }));
181
+ if (!attempted.ok)
182
+ return { kind: 'pending', retryAfter: 1 };
183
+ resolved = { kind: 'completed', result: attempted.value };
184
+ }
185
+ const result = resolved.result;
186
+ return gate(input, async (record) => {
187
+ return complete(record, result, input);
188
+ });
189
+ },
190
+ /** Drop protected result bodies only after retention; retain the action fence and never re-execute it. */
191
+ prune(operationId) {
192
+ return options.store.transaction(operationId, async (slot) => {
193
+ const record = slot.record;
194
+ if (record?.version !== undefined && record.version !== 1)
195
+ throw new Error('unsupported business operation version');
196
+ if (record?.execution?.phase === 'completed' && record.execution.retainUntil < now()) {
197
+ delete record.execution.result;
198
+ record.revisions = {};
199
+ }
200
+ });
201
+ },
202
+ };
203
+ }
204
+ export function operationApiResponse(outcome, success = result => Response.json(result)) {
205
+ return outcome.kind === 'approval_required' ? operationRequiredResponse(outcome.operation) : outcome.kind === 'pending' ? operationPendingResponse(outcome.retryAfter) : success(outcome.result);
206
+ }
207
+ /** A tool's missing extension is an ordinary tool error, not an OAuth refusal of the whole MCP client. */
208
+ export function operationMcpResponse(outcome) {
209
+ if (outcome instanceof BusinessOperationError)
210
+ return Response.json({ isError: true, content: [{ type: 'text', text: 'This tool requires user approval through Operations v2.' }] });
211
+ return outcome.kind === 'pending' ? operationPendingResponse(outcome.retryAfter) : Response.json(outcome.kind === 'approval_required' ? operationToolError(outcome.operation) : outcome.result);
212
+ }
@@ -1,3 +1,6 @@
1
+ import type { BusinessOperationStore } from './operations.js';
2
+ import type { BrowserCookieStore } from './browser.js';
3
+ import type { SessionStore } from './auth/session.js';
1
4
  import type { Pool } from 'pg';
2
5
  import type { ConversationStore } from './conversation/store.js';
3
6
  import type { IssuerStore } from './reference-auth/store.js';
@@ -24,7 +27,10 @@ export declare class PostgresCompanyDatabase {
24
27
  private put;
25
28
  private save;
26
29
  private meta;
30
+ businessOperations(namespace: string): BusinessOperationStore;
27
31
  conversations(namespace: string): ConversationStore;
32
+ browserCookies(namespace: string): BrowserCookieStore;
33
+ sessions(namespace: string): SessionStore;
28
34
  issuer(namespace: string): IssuerStore;
29
35
  /** Bounded maintenance; never deletes message receipts, events, grants or work intent. */
30
36
  cleanup(namespace: string, now: number, limit?: number): Promise<number>;
package/dist/postgres.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { initialConversationState } from './conversation/service.js';
2
- import { initialIssuerState } from './reference-auth/index.js';
3
+ import { initialIssuerState, upgradeIssuerState } from './reference-auth/index.js';
3
4
  import { clientRevisionKey, accountRevisionKey } from './reference-auth/store.js';
4
5
  import { PostgresRuntime, json } from './postgres-runtime.js';
5
6
  export { createPostgresPool, migratePostgres, postgresMigration, postgresSchemaVersion } from './postgres-runtime.js';
@@ -40,9 +41,26 @@ export class PostgresCompanyDatabase {
40
41
  await this.put(ns, kind, 'meta', value);
41
42
  return value;
42
43
  }
44
+ businessOperations(namespace) {
45
+ return { transaction: (operationId, action) => this.run(async () => {
46
+ await this.db.lock(JSON.stringify([namespace, 'business_operations', operationId]));
47
+ const slot = { record: await this.one(namespace, 'business_operations', operationId) };
48
+ if (slot.record && slot.record.version !== 1)
49
+ throw new Error('unsupported business operation version');
50
+ const result = await action(slot);
51
+ if (slot.record) {
52
+ if (slot.record.version !== 1 || slot.record.id !== operationId)
53
+ throw new TypeError('business operation binding');
54
+ await this.put(namespace, 'business_operations', operationId, slot.record);
55
+ }
56
+ return structuredClone(result);
57
+ }) };
58
+ }
43
59
  conversations(namespace) {
44
60
  const load = async (selection) => {
45
61
  const state = { ...await this.meta(namespace, 'conversation_meta', () => { const { version, generation } = initialConversationState(); return { version, generation }; }), conversations: {}, bindings: {}, messages: {}, work: [] };
62
+ if (state.version !== 2)
63
+ throw new Error('conversation reset required for draft 0.3');
46
64
  if (selection.messageKey)
47
65
  await this.db.lock(JSON.stringify([namespace, 'message', selection.messageKey]));
48
66
  let id = selection.conversationId;
@@ -185,11 +203,63 @@ export class PostgresCompanyDatabase {
185
203
  })),
186
204
  };
187
205
  }
206
+ browserCookies(namespace) {
207
+ const lock = (hash) => this.db.lock(JSON.stringify([namespace, 'browser_join', hash]));
208
+ return {
209
+ put: (hash, binding) => this.run(() => this.put(namespace, 'browser_cookies', hash, binding)),
210
+ get: hash => this.run(async () => await this.one(namespace, 'browser_cookies', hash) ?? null),
211
+ remove: hash => this.run(async () => { await this.sqlRun('DELETE FROM poppy_server.records WHERE ns=$1 AND kind=$2 AND id=$3', [namespace, 'browser_cookies', hash]); }),
212
+ putJoin: (hash, binding) => this.run(async () => { await lock(hash); await this.put(namespace, 'browser_joins', hash, binding); }),
213
+ authorizeJoin: (hash, principal, at) => this.run(async () => {
214
+ await lock(hash);
215
+ const row = await this.one(namespace, 'browser_joins', hash);
216
+ if (!row || row.expiresAt <= at || row.principal)
217
+ return false;
218
+ row.principal = structuredClone(principal);
219
+ await this.put(namespace, 'browser_joins', hash, row);
220
+ return true;
221
+ }),
222
+ consumeJoin: hash => this.run(async () => {
223
+ await lock(hash);
224
+ const row = await this.one(namespace, 'browser_joins', hash);
225
+ if (row)
226
+ await this.sqlRun('DELETE FROM poppy_server.records WHERE ns=$1 AND kind=$2 AND id=$3', [namespace, 'browser_joins', hash]);
227
+ return row ?? null;
228
+ }),
229
+ };
230
+ }
231
+ sessions(namespace) {
232
+ const load = async (clientId, sid) => {
233
+ await this.db.lock(JSON.stringify([namespace, 'resource_session_client', clientId]));
234
+ await this.db.lock(JSON.stringify([namespace, 'resource_session', clientId, sid]));
235
+ const meta = await this.meta(namespace, 'session_meta', () => ({ version: 1, generation: randomUUID() }));
236
+ if (meta.version !== 1)
237
+ throw new Error('unsupported Session state version');
238
+ return { ...meta, record: await this.one(namespace, 'resource_sessions', JSON.stringify([clientId, sid])) ?? null };
239
+ };
240
+ return {
241
+ read: (clientId, sid) => this.run(() => load(clientId, sid)),
242
+ transaction: (clientId, sid, at, change) => this.run(async () => this.atomic(async () => {
243
+ if (!Number.isFinite(at))
244
+ throw new TypeError('Session clock');
245
+ const snapshot = await load(clientId, sid);
246
+ const counts = (await this.sqlGet("SELECT count(*) AS n,min((body::jsonb->>'expiresAt')::double precision) AS expiry FROM poppy_server.records WHERE ns=$1 AND kind='resource_sessions' AND body::jsonb->>'clientId'=$2 AND body::jsonb->>'ended'='false' AND (body::jsonb->>'expiresAt')::double precision>$3", [namespace, clientId, at]));
247
+ const state = { ...snapshot, activeCount: Number(counts.n), earliestExpiry: counts.expiry === null ? null : Number(counts.expiry) }, generation = state.generation, result = change(state);
248
+ if (result && typeof result.then === 'function')
249
+ throw new TypeError('async Session transaction');
250
+ if (state.version !== 1 || state.generation !== generation || (state.record && (state.record.clientId !== clientId || state.record.sid !== sid)))
251
+ throw new TypeError('Session state binding');
252
+ if (state.record)
253
+ await this.put(namespace, 'resource_sessions', JSON.stringify([clientId, sid]), state.record);
254
+ return structuredClone(result);
255
+ })),
256
+ };
257
+ }
188
258
  issuer(namespace) {
189
- const kinds = ['sessions', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
259
+ const kinds = ['sessions', 'sessionKeys', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
190
260
  const load = async (selection) => {
191
261
  await this.db.lock(JSON.stringify([namespace, 'issuer']));
192
- const state = { ...await this.meta(namespace, 'issuer_meta', () => { const { version, generation, nonces } = initialIssuerState(); return { version, generation, nonces }; }), sessions: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {} };
262
+ const state = { ...await this.meta(namespace, 'issuer_meta', () => { const { version, generation, nonces } = initialIssuerState(); return { version, generation, nonces }; }), sessions: {}, sessionKeys: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {} };
193
263
  const get = async (kind, id) => {
194
264
  if (!id)
195
265
  return;
@@ -201,6 +271,23 @@ export class PostgresCompanyDatabase {
201
271
  for (const row of await this.rows(namespace, kind, where, params))
202
272
  state[kind][row.id] = JSON.parse(row.body);
203
273
  };
274
+ if (state.version === 3 || state.version === 4) {
275
+ for (const kind of kinds)
276
+ await find(kind, '');
277
+ const before = structuredClone(state);
278
+ upgradeIssuerState(state);
279
+ for (const kind of kinds)
280
+ await this.save(namespace, kind, before[kind], state[kind]);
281
+ await this.put(namespace, 'issuer_meta', 'meta', { version: state.version, generation: state.generation, nonces: state.nonces });
282
+ for (const kind of kinds)
283
+ state[kind] = {};
284
+ }
285
+ if (state.version !== 5)
286
+ throw new Error('unsupported issuer state version');
287
+ if ((selection.kind === 'token' || selection.kind === 'sign_in') && selection.sessionKey) {
288
+ await get('sessionKeys', selection.sessionKey);
289
+ await get('sessions', state.sessionKeys[selection.sessionKey]);
290
+ }
204
291
  if (selection.kind === 'access')
205
292
  await get('accessTokens', selection.hash);
206
293
  if (selection.kind === 'sign_in') {
@@ -210,7 +297,7 @@ export class PostgresCompanyDatabase {
210
297
  await get('accessTokens', selection.accessHash);
211
298
  for (const key of selection.replayKeys)
212
299
  await get('replay', key);
213
- const counts = (await this.sqlGet("SELECT count(*) AS n, sum(CASE WHEN (body::jsonb#>>'{clientId}')=$1 THEN 1 ELSE 0 END) AS client FROM poppy_server.records WHERE ns=$2 AND kind='signIns' AND (body::jsonb#>>'{status}') IN ('pending','approved','verifying') AND ((body::jsonb#>>'{expiresAt}')::double precision)>$3", [selection.clientId ?? '', namespace, selection.at]));
300
+ const counts = (await this.sqlGet("SELECT count(*) AS n, sum(CASE WHEN (body::jsonb#>>'{clientId}')=$1 THEN 1 ELSE 0 END) AS client FROM poppy_server.records WHERE ns=$2 AND kind='signIns' AND (body::jsonb#>>'{status}') IN ('pending','approved','verifying') AND ((body::jsonb#>>'{status}')='verifying' OR ((body::jsonb#>>'{expiresAt}')::double precision)>$3)", [selection.clientId ?? '', namespace, selection.at]));
214
301
  state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(counts.n), pendingForClient: Number(counts.client ?? 0) };
215
302
  }
216
303
  if (selection.kind === 'principal') {
@@ -226,7 +313,6 @@ export class PostgresCompanyDatabase {
226
313
  await get('signIns', selection.revocationKey);
227
314
  }
228
315
  if (selection.kind === 'token') {
229
- await get('sessions', selection.sessionId);
230
316
  await get('codes', selection.codeHash);
231
317
  await get('refreshTokens', selection.refreshHash);
232
318
  for (const key of selection.replayKeys)
@@ -294,7 +380,7 @@ export class PostgresCompanyDatabase {
294
380
  return this.run(async () => {
295
381
  await this.db.lock(JSON.stringify([namespace, 'issuer']));
296
382
  const rows = await this.sqlAll(`SELECT kind,id FROM poppy_server.records AS retained WHERE ns=$1 AND
297
- ((kind IN ('authorizations','codes','accessTokens') AND ((body::jsonb#>>'{expiresAt}')::double precision)<=$2) OR (kind='signIns' AND ((body::jsonb#>>'{expiresAt}')::double precision)+30000<$3) OR (kind='replay' AND (body::jsonb#>>'{}')::double precision<$4)
383
+ ((kind IN ('authorizations','codes','accessTokens','browser_cookies','browser_joins','resource_sessions') AND ((body::jsonb#>>'{expiresAt}')::double precision)<=$2) OR (kind='signIns' AND coalesce(body::jsonb->>'status','')!='verifying' AND ((body::jsonb#>>'{expiresAt}')::double precision)+30000<$3) OR (kind='replay' AND (body::jsonb#>>'{}')::double precision<$4)
298
384
  OR (kind='refreshTokens' AND NOT EXISTS (SELECT 1 FROM poppy_server.records AS lineage WHERE lineage.ns=retained.ns AND lineage.kind='grants' AND lineage.id=retained.body::jsonb#>>'{}' AND (lineage.body::jsonb->>'expiresAt')::double precision>$5 AND lineage.body::jsonb->>'revoked'='false'))) LIMIT $6`, [namespace, now, now, now, now, limit]);
299
385
  for (const row of rows)
300
386
  await this.sqlRun("DELETE FROM poppy_server.records WHERE ns=$1 AND kind=$2 AND id=$3", [namespace, row.kind, row.id]);
@@ -0,0 +1,51 @@
1
+ import type { JsonObject } from './wire.js';
2
+ export interface BusinessOperation {
3
+ operation_id: string;
4
+ revision: string;
5
+ summary: string;
6
+ terms?: JsonObject;
7
+ expires_at: string;
8
+ [key: string]: unknown;
9
+ }
10
+ export interface OperationApproval {
11
+ operation_id: string;
12
+ revision: string;
13
+ [key: string]: unknown;
14
+ }
15
+ export declare const operationsV2: {
16
+ readonly version: "2";
17
+ };
18
+ export declare function supportsOperations(document: {
19
+ extensions?: unknown;
20
+ }): boolean;
21
+ export declare function parseBusinessOperation(value: unknown): BusinessOperation;
22
+ /** RFC3339 permits a leap second; JavaScript Date.parse does not. */
23
+ export declare function operationExpiry(value: string): number;
24
+ export declare function parseOperationApproval(value: unknown): OperationApproval;
25
+ export declare function parseApprovalHeader(value: string | null): OperationApproval | undefined;
26
+ export declare function approvalHeader(value: OperationApproval): string;
27
+ export declare const approvalMetaKey = "personalagentprotocol.org/approval";
28
+ export declare function mcpApproval(params: unknown): OperationApproval | undefined;
29
+ export type OperationRequest = {
30
+ channel: 'api';
31
+ method: string;
32
+ url: string;
33
+ body: string | null;
34
+ } | {
35
+ channel: 'mcp';
36
+ url: string;
37
+ name: string;
38
+ arguments: JsonObject;
39
+ } | {
40
+ channel: 'conversation';
41
+ endpoint: string;
42
+ conversationId: string;
43
+ };
44
+ export declare function operationRequest(value: OperationRequest): OperationRequest;
45
+ export declare function requestFingerprint(value: OperationRequest): string;
46
+ export declare function operationRequiredResponse(operation: BusinessOperation): Response;
47
+ export declare function operationToolError(operation: BusinessOperation): JsonObject;
48
+ /** Base64 preserves exact API body bytes. Bind the complete URL, including its query. */
49
+ export declare function captureApiOperationRequest(request: Request): Promise<Extract<OperationRequest, {
50
+ channel: 'api';
51
+ }>>;
@@ -0,0 +1,98 @@
1
+ import { parse, serialize, getMember } from '@agentsig/structured-fields';
2
+ import { canonical, id, object, string, ValidationError } from './wire.js';
3
+ import { secureUrl } from '../network/transport.js';
4
+ export const operationsV2 = { version: '2' };
5
+ export function supportsOperations(document) {
6
+ const extensions = document.extensions;
7
+ return !!extensions && typeof extensions === 'object' && !Array.isArray(extensions)
8
+ && extensions.operations?.version === '2';
9
+ }
10
+ export function parseBusinessOperation(value) {
11
+ const operation = object(value, 'operation');
12
+ id(operation.operation_id, 'operation.operation_id');
13
+ id(operation.revision, 'operation.revision');
14
+ string(operation.summary, 'operation.summary');
15
+ if ('terms' in operation)
16
+ object(operation.terms, 'operation.terms');
17
+ const date = string(operation.expires_at, 'operation.expires_at');
18
+ const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|[+-](\d{2}):(\d{2}))$/.exec(date);
19
+ if (!match)
20
+ throw new ValidationError('operation.expires_at');
21
+ const [, y, m, d, h, min, s, oh = '0', om = '0'] = match;
22
+ const monthEnd = new Date(0);
23
+ monthEnd.setUTCFullYear(+y, +m, 0);
24
+ const days = monthEnd.getUTCDate();
25
+ if (+m < 1 || +m > 12 || +d < 1 || +d > days || +h > 23 || +min > 59 || +s > 60 || +oh > 23 || +om > 59 || !Number.isFinite(operationExpiry(date)))
26
+ throw new ValidationError('operation.expires_at');
27
+ return structuredClone(operation);
28
+ }
29
+ /** RFC3339 permits a leap second; JavaScript Date.parse does not. */
30
+ export function operationExpiry(value) {
31
+ return /:60(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/.test(value)
32
+ ? Date.parse(value.replace(/:60(?=\.\d+|[Zz]|[+-])/, ':59')) + 1000 : Date.parse(value);
33
+ }
34
+ export function parseOperationApproval(value) {
35
+ const approval = object(value, 'approval');
36
+ id(approval.operation_id, 'approval.operation_id');
37
+ id(approval.revision, 'approval.revision');
38
+ return { ...approval };
39
+ }
40
+ export function parseApprovalHeader(value) {
41
+ if (value === null)
42
+ return;
43
+ try {
44
+ const dictionary = parse(value, 'dictionary');
45
+ const text = (name) => { const member = getMember(dictionary, name); return member?.kind === 'item' && member.bare.kind === 'string' ? member.bare.value : undefined; };
46
+ return parseOperationApproval({ operation_id: text('operation_id'), revision: text('revision') });
47
+ }
48
+ catch {
49
+ return;
50
+ } // Invalid Structured Fields are ignored as a whole (RFC 9651 section 2.2).
51
+ }
52
+ export function approvalHeader(value) {
53
+ const approval = parseOperationApproval(value);
54
+ return serialize({ kind: 'dictionary', entries: [
55
+ ['operation_id', { kind: 'item', bare: { kind: 'string', value: approval.operation_id }, parameters: [] }],
56
+ ['revision', { kind: 'item', bare: { kind: 'string', value: approval.revision }, parameters: [] }],
57
+ ] });
58
+ }
59
+ export const approvalMetaKey = 'personalagentprotocol.org/approval';
60
+ export function mcpApproval(params) {
61
+ const meta = object(params, 'params')._meta;
62
+ if (meta === undefined)
63
+ return;
64
+ const value = object(meta, '_meta')[approvalMetaKey];
65
+ return value === undefined ? undefined : parseOperationApproval(value);
66
+ }
67
+ export function operationRequest(value) {
68
+ object(value, 'operation.request');
69
+ if (value.channel === 'api') {
70
+ secureUrl(value.url);
71
+ if (!/^[A-Z]+$/.test(value.method) || !(value.body === null || typeof value.body === 'string'))
72
+ throw new ValidationError('operation.request');
73
+ }
74
+ else if (value.channel === 'mcp') {
75
+ secureUrl(value.url);
76
+ string(value.name, 'tool.name');
77
+ object(value.arguments, 'tool.arguments');
78
+ }
79
+ else if (value.channel === 'conversation') {
80
+ secureUrl(value.endpoint);
81
+ id(value.conversationId, 'conversation_id');
82
+ }
83
+ else
84
+ throw new ValidationError('operation.channel');
85
+ return structuredClone(value);
86
+ }
87
+ export function requestFingerprint(value) { return canonical(operationRequest(value)); }
88
+ export function operationRequiredResponse(operation) {
89
+ return Response.json({ error: 'approval_required', operation: parseBusinessOperation(operation) }, { status: 403, headers: { 'Cache-Control': 'no-store' } });
90
+ }
91
+ export function operationToolError(operation) {
92
+ const parsed = parseBusinessOperation(operation);
93
+ return { isError: true, content: [{ type: 'text', text: parsed.summary }], structuredContent: { operation: parsed } };
94
+ }
95
+ /** Base64 preserves exact API body bytes. Bind the complete URL, including its query. */
96
+ export async function captureApiOperationRequest(request) {
97
+ return { channel: 'api', method: request.method, url: request.url, body: request.body === null ? null : Buffer.from(await request.clone().arrayBuffer()).toString('base64') };
98
+ }