@poppykit/server 0.4.0 → 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.
- package/API.md +10 -3
- package/AUTH.md +120 -124
- package/CHANGELOG.md +13 -1
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +12 -11
- package/DELIVERY.md +2 -0
- package/INTEGRATIONS.md +74 -81
- package/OPERATIONS.md +142 -0
- package/README.md +11 -20
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +56 -17
- package/dist/adapter-contract.d.ts +18 -0
- package/dist/adapter-contract.js +79 -0
- package/dist/auth/authenticator.d.ts +3 -1
- package/dist/auth/authenticator.js +34 -29
- package/dist/auth/authority.d.ts +8 -2
- package/dist/auth/authority.js +7 -2
- package/dist/auth/session.d.ts +47 -0
- package/dist/auth/session.js +94 -0
- package/dist/browser.d.ts +15 -16
- package/dist/browser.js +65 -33
- package/dist/conversation/service.d.ts +11 -1
- package/dist/conversation/service.js +39 -9
- package/dist/feedback.d.ts +1 -1
- package/dist/feedback.js +5 -5
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +8 -4
- package/dist/network/discovery.d.ts +31 -13
- package/dist/network/discovery.js +103 -66
- package/dist/operations.d.ts +115 -0
- package/dist/operations.js +212 -0
- package/dist/postgres.d.ts +6 -0
- package/dist/postgres.js +72 -4
- package/dist/protocol/operations.d.ts +51 -0
- package/dist/protocol/operations.js +98 -0
- package/dist/protocol/session.d.ts +28 -0
- package/dist/protocol/session.js +93 -0
- package/dist/protocol/sse.d.ts +1 -0
- package/dist/protocol/sse.js +1 -1
- package/dist/protocol/wire.d.ts +8 -4
- package/dist/protocol/wire.js +37 -15
- package/dist/reference-auth/index.d.ts +10 -16
- package/dist/reference-auth/index.js +225 -430
- package/dist/sqlite.d.ts +11 -0
- package/dist/sqlite.js +84 -4
- package/examples/operations.mjs +18 -0
- package/examples/quickstart.mjs +8 -5
- package/migrations/RETIREMENT.md +77 -0
- 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
|
+
}
|
package/dist/postgres.d.ts
CHANGED
|
@@ -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,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { initialConversationState } from './conversation/service.js';
|
|
2
3
|
import { initialIssuerState, upgradeIssuerState } from './reference-auth/index.js';
|
|
3
4
|
import { clientRevisionKey, accountRevisionKey } from './reference-auth/store.js';
|
|
@@ -40,6 +41,21 @@ 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: [] };
|
|
@@ -187,6 +203,58 @@ export class PostgresCompanyDatabase {
|
|
|
187
203
|
})),
|
|
188
204
|
};
|
|
189
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
|
+
}
|
|
190
258
|
issuer(namespace) {
|
|
191
259
|
const kinds = ['sessions', 'sessionKeys', 'grants', 'refreshTokens', 'authorizations', 'codes', 'signIns', 'accessTokens', 'replay'];
|
|
192
260
|
const load = async (selection) => {
|
|
@@ -203,7 +271,7 @@ export class PostgresCompanyDatabase {
|
|
|
203
271
|
for (const row of await this.rows(namespace, kind, where, params))
|
|
204
272
|
state[kind][row.id] = JSON.parse(row.body);
|
|
205
273
|
};
|
|
206
|
-
if (state.version === 3) {
|
|
274
|
+
if (state.version === 3 || state.version === 4) {
|
|
207
275
|
for (const kind of kinds)
|
|
208
276
|
await find(kind, '');
|
|
209
277
|
const before = structuredClone(state);
|
|
@@ -214,7 +282,7 @@ export class PostgresCompanyDatabase {
|
|
|
214
282
|
for (const kind of kinds)
|
|
215
283
|
state[kind] = {};
|
|
216
284
|
}
|
|
217
|
-
if (state.version !==
|
|
285
|
+
if (state.version !== 5)
|
|
218
286
|
throw new Error('unsupported issuer state version');
|
|
219
287
|
if ((selection.kind === 'token' || selection.kind === 'sign_in') && selection.sessionKey) {
|
|
220
288
|
await get('sessionKeys', selection.sessionKey);
|
|
@@ -229,7 +297,7 @@ export class PostgresCompanyDatabase {
|
|
|
229
297
|
await get('accessTokens', selection.accessHash);
|
|
230
298
|
for (const key of selection.replayKeys)
|
|
231
299
|
await get('replay', key);
|
|
232
|
-
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]));
|
|
233
301
|
state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(counts.n), pendingForClient: Number(counts.client ?? 0) };
|
|
234
302
|
}
|
|
235
303
|
if (selection.kind === 'principal') {
|
|
@@ -312,7 +380,7 @@ export class PostgresCompanyDatabase {
|
|
|
312
380
|
return this.run(async () => {
|
|
313
381
|
await this.db.lock(JSON.stringify([namespace, 'issuer']));
|
|
314
382
|
const rows = await this.sqlAll(`SELECT kind,id FROM poppy_server.records AS retained WHERE ns=$1 AND
|
|
315
|
-
((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)
|
|
316
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]);
|
|
317
385
|
for (const row of rows)
|
|
318
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MetadataResolver } from '../network/discovery.js';
|
|
2
|
+
export interface SessionClaims {
|
|
3
|
+
iss: string;
|
|
4
|
+
aud: string;
|
|
5
|
+
sid: string;
|
|
6
|
+
sub: string;
|
|
7
|
+
agent_id?: string;
|
|
8
|
+
htm: string;
|
|
9
|
+
htu: string;
|
|
10
|
+
iat: number;
|
|
11
|
+
jti: string;
|
|
12
|
+
ath?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface VerifiedSession extends SessionClaims {
|
|
15
|
+
delegationExpiresAt: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function sessionRequestUrl(value: string): string;
|
|
18
|
+
export declare function parseSessionClaims(value: unknown): SessionClaims;
|
|
19
|
+
/** Verifies identity only. The resource must separately admit/continue the client_id + sid under its store fence. */
|
|
20
|
+
export declare function verifySessionJwt(jwt: string | null, expected: {
|
|
21
|
+
companyId: string;
|
|
22
|
+
method: string;
|
|
23
|
+
url: string;
|
|
24
|
+
nowMs: number;
|
|
25
|
+
metadata: MetadataResolver;
|
|
26
|
+
accessToken?: string;
|
|
27
|
+
tokenClientId?: string;
|
|
28
|
+
}): Promise<VerifiedSession>;
|