@poppykit/server 0.2.0 → 0.3.1
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 +5 -0
- package/AUTH.md +37 -0
- package/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +10 -2
- package/DELIVERY.md +3 -2
- package/README.md +6 -4
- package/RELEASING.md +2 -2
- package/STORAGE.md +7 -5
- package/dist/adapter-contract.js +28 -1
- package/dist/conversation/service.js +12 -5
- package/dist/postgres.js +5 -4
- package/dist/reference-auth/index.d.ts +9 -0
- package/dist/reference-auth/index.js +32 -17
- package/dist/sqlite.js +5 -4
- package/package.json +2 -2
package/API.md
CHANGED
|
@@ -18,6 +18,11 @@ idempotency and reconciliation. Existing issuers implement `ExternalAuthorityAda
|
|
|
18
18
|
and do not need the optional reference issuer. Your framework owns HTTPS routing,
|
|
19
19
|
trusted public URLs, ingress limits, request cancellation and private-error handling.
|
|
20
20
|
|
|
21
|
+
Direct reference issuers now require `authorizationResultCipher` before
|
|
22
|
+
`beginAuthorization`; same-decision `completeAuthorization` recovers the encrypted
|
|
23
|
+
callback through its original validity window. See [AUTH.md](AUTH.md) for custody,
|
|
24
|
+
revocation and one-time exchange limits. No package subpath or driver contract changed.
|
|
25
|
+
|
|
21
26
|
Run the [quickstart](examples/quickstart.mjs) without a listener or external service.
|
|
22
27
|
|
|
23
28
|
The optional Postgres entry point ships its adapters, `createPostgresPool`,
|
package/AUTH.md
CHANGED
|
@@ -12,6 +12,43 @@ The host authenticates the human and protects consent with its own browser
|
|
|
12
12
|
session/CSRF controls; pass a high-entropy browser binding. Registered callback
|
|
13
13
|
URLs must be HTTPS on the client metadata domain and exactly match registration.
|
|
14
14
|
|
|
15
|
+
Direct hosts must configure `authorizationResultCipher: { seal, open }` with
|
|
16
|
+
synchronous authenticated encryption and a durable protected key before calling
|
|
17
|
+
`beginAuthorization`. Hosts already using the optional client provider can reuse
|
|
18
|
+
its `createResultCipher` instance. Other hosts supply the same structural contract;
|
|
19
|
+
no client package or database driver is loaded by the reference issuer.
|
|
20
|
+
|
|
21
|
+
`completeAuthorization(id, browserBinding, decision)` commits one decision and
|
|
22
|
+
an encrypted callback receipt atomically. Repeating the same decision returns
|
|
23
|
+
the same URL after restart; changed browser/account/scopes/decision and stale
|
|
24
|
+
account revocation/reset fail. Approval recovery expires at the earlier of the
|
|
25
|
+
original authorization deadline and code expiry; denial uses the original deadline.
|
|
26
|
+
Recovery never mints a replacement code or extends consent. The host must retain
|
|
27
|
+
its authenticated owner/installation/session binding and chosen decision durably,
|
|
28
|
+
then reconcile delivery outside transactions. Never retry an uncertain code exchange.
|
|
29
|
+
|
|
30
|
+
`withCurrent` checks token, Session and grant expiry immediately before protected
|
|
31
|
+
actions, including after body reads and long-poll waits. Streams close at token
|
|
32
|
+
expiry and recheck current authority for each emitted frame; reconnect with fresh
|
|
33
|
+
authentication. `withAccepted` intentionally ignores admission-token expiry while
|
|
34
|
+
preserving the accepted scope ceiling, reset generation and configured revocation policy.
|
|
35
|
+
|
|
36
|
+
Account Token rotation retains all issued hashes through the fixed grant lifetime.
|
|
37
|
+
Replaying any ancestor revokes its entire lineage and signs its Sessions out;
|
|
38
|
+
rotation never extends the grant. Client authentication remains mandatory, while
|
|
39
|
+
Session Tokens can move to a new DPoP key as the protocol permits. Shared provider
|
|
40
|
+
signing does not bind a copied refresh token to one provider enrollment. Keep
|
|
41
|
+
tokens secret and serialize rotation; never retry a lost token response blindly.
|
|
42
|
+
Bounded database cleanup removes hashes only after their lineage is expired,
|
|
43
|
+
revoked or absent. No plaintext callback code is persisted.
|
|
44
|
+
|
|
45
|
+
Upgrade prerequisite: older issuers discarded ancestors beyond the previous hash.
|
|
46
|
+
Those missing hashes cannot be reconstructed by this update. Before public
|
|
47
|
+
enablement, revoke every pre-upgrade live lineage through the issuer's normal
|
|
48
|
+
revocation/disconnect flow and require fresh consent, or prove it has expired.
|
|
49
|
+
Until then, the full-ancestor guarantee applies only to newly created lineages.
|
|
50
|
+
This update does not reset stored data or silently revoke legacy grants.
|
|
51
|
+
|
|
15
52
|
Enable reference Device sign-in with `device: { endpoint, verificationUri,
|
|
16
53
|
scopes }`. Route the configured device endpoint to `deviceAuthorization`.
|
|
17
54
|
On the human page call `beginDeviceAuthorization(userCode, browserBinding)` and
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
Support Node 24.21.0 and later within Node 24 under the owner-accepted runtime policy.
|
|
6
|
+
Build, verification and publication tooling read .node-version.
|
|
7
|
+
|
|
8
|
+
## 0.3.0
|
|
9
|
+
|
|
10
|
+
Require Node 24 >=24.22.1 within 24.x. Enforce current-token expiry after waits and
|
|
11
|
+
at streamed delivery while preserving already-accepted work's authority ceiling.
|
|
12
|
+
Deduplicate reserved string output IDs and retain refresh-token ancestors through
|
|
13
|
+
fixed grant expiry so replay revokes descendants. Direct Sign-In hosts must supply
|
|
14
|
+
the documented durable `authorizationResultCipher`; identical decisions recover
|
|
15
|
+
their original encrypted callback. Before public enablement, revoke pre-upgrade
|
|
16
|
+
live grant lineages and obtain fresh consent, or prove their fixed expiry.
|
|
17
|
+
Required release verification now includes isolated PostgreSQL contracts.
|
|
18
|
+
|
|
3
19
|
## 0.2.0 (release candidate)
|
|
4
20
|
|
|
5
21
|
Add bundled Postgres company stores/coordinator with explicit migrations, atomic host transaction composition, savepoints and pool lifecycle.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -5,8 +5,8 @@ and [guides](https://personalagentprotocol.org/docs/0.2/guides). Tests/snapshots
|
|
|
5
5
|
supporting evidence. Demo/business workflows and individual extensions are excluded.
|
|
6
6
|
Code is [MIT](LICENSE); confirm rights for third-party contributions.
|
|
7
7
|
|
|
8
|
-
Use Node 24.
|
|
9
|
-
Install the packageManager-pinned pnpm and run pnpm install --frozen-lockfile.
|
|
8
|
+
Use Node 24.21.0, TypeScript 5.9.3 and @types/node 24.10.0.
|
|
9
|
+
Install the packageManager-pinned pnpm and run pnpm install --frozen-lockfile --ignore-scripts.
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
12
|
npm run typecheck
|
|
@@ -36,3 +36,11 @@ Preserve credential/destination validation, private errors and persisted data.
|
|
|
36
36
|
Report checks actually run and limits. Use Issues for non-sensitive defects and
|
|
37
37
|
[SECURITY.md](SECURITY.md) for vulnerabilities. Follow [RELEASING.md](RELEASING.md)
|
|
38
38
|
before tagging/publishing; obtain explicit owner authorization.
|
|
39
|
+
|
|
40
|
+
Required CI also runs scripts/postgres-ci.mjs in a dedicated disposable PostgreSQL17
|
|
41
|
+
cluster with POPPY_DISPOSABLE_POSTGRES=1, D1_LOCAL=1 and explicit loopback PGHOST,
|
|
42
|
+
PGPORT, PGUSER, PGPASSWORD, PGDATABASE. Build production/tests first; use a fresh
|
|
43
|
+
database without SDK schemas. Never point it at shared/cloud data. It migrates
|
|
44
|
+
only this package's schemas, runs all named durable suites, rejects skips/missing
|
|
45
|
+
discovery and leaves cluster destruction to its owner. Default source-suite skips
|
|
46
|
+
do not replace this gate. See the workflow for the pinned image and setup.
|
package/DELIVERY.md
CHANGED
|
@@ -53,8 +53,9 @@ SQLite reconciliation; deployments can implement the same notification contract
|
|
|
53
53
|
with their own distributed adapter. Lost/duplicate hints cannot erase a commit.
|
|
54
54
|
SQLite event windows and append operations do not reload complete event histories.
|
|
55
55
|
|
|
56
|
-
Server streams default to 30 seconds and apply pull
|
|
57
|
-
current authority before
|
|
56
|
+
Server streams default to 30 seconds, end earlier at token expiry and apply pull
|
|
57
|
+
backpressure, rechecking current authority before every emitted frame. Reconnect
|
|
58
|
+
with a fresh credential and the last handled event ID. This bounded-connection policy omits
|
|
58
59
|
periodic idle comments; proxies may close an idle stream sooner, and the client
|
|
59
60
|
reconnects. Longer-lived deployments should supply heartbeat handling at their
|
|
60
61
|
HTTP adapter. Hosts must propagate disconnect/AbortSignal and body cancellation.
|
package/README.md
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
# PoppyKit server
|
|
2
2
|
|
|
3
|
+
The Node 24.21.0 minimum is an owner-accepted compatibility baseline. Use a current patched Node 24 runtime where available; this minimum does not establish that older runtimes contain later security fixes. Build and release CI read `.node-version`.
|
|
4
|
+
|
|
3
5
|
TypeScript SDK for companies implementing [Personal Agent Protocol draft 0.2](https://personalagentprotocol.org/docs/0.2/spec).
|
|
4
6
|
Verify requests, serve conversations and enforce current authority. Your company
|
|
5
7
|
supplies accounts, consent UI, agent, business tools and HTTPS routing. [MIT](LICENSE).
|
|
6
8
|
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
This source contains unreleased repairs; final version selection and publication are pending.
|
|
10
12
|
|
|
11
|
-
Requires Node **24.
|
|
12
|
-
|
|
13
|
+
Requires Node **24.x, at least 24.21.0**, and ESM. CI tests Node 24.21.0 on Linux, macOS and Windows;
|
|
14
|
+
other Node majors are unsupported. SQLite is optional/experimental and
|
|
13
15
|
requires local filesystem locking.
|
|
14
16
|
|
|
15
17
|
```sh
|
|
16
|
-
npm install @poppykit/server
|
|
18
|
+
npm install @poppykit/server
|
|
17
19
|
```
|
|
18
20
|
|
|
19
21
|
## Configure a service
|
package/RELEASING.md
CHANGED
|
@@ -6,7 +6,7 @@ changing runtime APIs. Each new version needs its own source/artifact evidence.
|
|
|
6
6
|
|
|
7
7
|
## Before tagging
|
|
8
8
|
|
|
9
|
-
- Confirm npm ownership, unused versions, supported Node 24.
|
|
9
|
+
- Confirm npm ownership, unused versions, supported Node 24.x (at least 24.21.0) and npm>=11.5.1.
|
|
10
10
|
Keep the pinned compiler/dependencies; do not upgrade tooling during a release.
|
|
11
11
|
- Update version, changelog and shipped docs. CLI's client dependency and both
|
|
12
12
|
candidate JSON files must match exact final SDK versions/full source SHAs.
|
|
@@ -47,7 +47,7 @@ guard requires its tested client tarball integrity to equal the published one.
|
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
4. Wait for candidate, fast, contracts, integration, packaged-linux/windows/macos,
|
|
50
|
-
validation and gate to succeed. Missing, failed, canceled or skipped prerequisites
|
|
50
|
+
validation, postgres and gate to succeed. The postgres job uses its own disposable PostgreSQL17 cluster and the same-run checked candidate/harness; zero discovery or any skipped database case fails. Missing, failed, canceled or skipped prerequisites
|
|
51
51
|
fail closed. Then follow the Actions environment-approval link. Do not bypass it.
|
|
52
52
|
5. The publisher rechecks source/tag/ref/version, trusted manifest digest, unchanged
|
|
53
53
|
tarball metadata/bytes, owner enablement, registry absence and dependency integrity.
|
package/STORAGE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Company persistence and authorization
|
|
2
2
|
|
|
3
|
-
Request paths use targeted domain selections, not whole-state rewrites. Node 24.
|
|
3
|
+
Request paths use targeted domain selections, not whole-state rewrites. Node 24.x, at least 24.21.0, is required.
|
|
4
4
|
The optional `@poppykit/server/sqlite` entry point exports `SqliteCompanyDatabase`.
|
|
5
5
|
The core and reference issuer entry points do not import SQLite. Consumers can
|
|
6
6
|
implement `ConversationStore`, `IssuerStore` and `AuthorityCoordinator` themselves.
|
|
7
|
-
The tested Node 24.
|
|
7
|
+
The tested Node 24.21.0 runtime marks `node:sqlite` experimental.
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
10
|
import { SqliteCompanyDatabase } from '@poppykit/server/sqlite';
|
|
@@ -35,7 +35,7 @@ with correct SQLite locks; network-mounted multi-host SQLite is unsupported.
|
|
|
35
35
|
## Domain records and guarantees
|
|
36
36
|
|
|
37
37
|
Every primary key includes a namespace (use the exact full issuer). Sessions,
|
|
38
|
-
grants,
|
|
38
|
+
grants, every live-lineage Account Token hash, authorization requests, codes, access-token
|
|
39
39
|
hashes, replay keys, conversation metadata, shared account bindings, message
|
|
40
40
|
receipts and work intent are separate records. Ordered events are a separate table
|
|
41
41
|
keyed by namespace/conversation/sequence. Indexes cover client, account, grant,
|
|
@@ -71,7 +71,9 @@ its own idempotency key (such as the stable work ID), or the application must re
|
|
|
71
71
|
an uncertain result for reconciliation. Never claim universal exactly-once execution.
|
|
72
72
|
|
|
73
73
|
`cleanup(namespace, now, limit)` bounds deletion to at most 1000 expired short-lived
|
|
74
|
-
codes, authorizations, access tokens
|
|
74
|
+
codes, authorizations, access tokens, replay entries or expired/revoked/orphaned
|
|
75
|
+
Account Token hashes. Every used hash for a live lineage remains replay-detectable
|
|
76
|
+
through its fixed expiry; retained size follows rotation volume. It does not delete grants,
|
|
75
77
|
work intent, message receipts or conversation events. Receipt identity remains
|
|
76
78
|
through retention for the conversation lifetime. Finished work pruning is
|
|
77
79
|
per selected conversation binding group; unrelated conversation growth cannot
|
|
@@ -116,7 +118,7 @@ reset or history deletion was added.
|
|
|
116
118
|
`@poppykit/server/postgres` ships `PostgresCompanyDatabase`, explicit migrations,
|
|
117
119
|
and `createPostgresPool` in this package. Install optional peer `pg@8.23.1` when
|
|
118
120
|
using that subpath. Core/reference-auth imports load neither database driver.
|
|
119
|
-
Supported baseline: Node 24.
|
|
121
|
+
Supported baseline: Node 24.x (at least 24.21.0), PostgreSQL 17, including Supabase transaction pooling.
|
|
120
122
|
No application, Google login or Supabase Auth dependency is required.
|
|
121
123
|
|
|
122
124
|
```ts
|
package/dist/adapter-contract.js
CHANGED
|
@@ -50,7 +50,7 @@ function serviceOn(store, clock) {
|
|
|
50
50
|
return principal;
|
|
51
51
|
},
|
|
52
52
|
};
|
|
53
|
-
return createConversationService({ endpoint, store, authenticator, policy, clock, leaseMs: 1000 });
|
|
53
|
+
return createConversationService({ endpoint, store, authenticator, policy, clock, leaseMs: 1000, supportedScopes: ['poppy:read'] });
|
|
54
54
|
}
|
|
55
55
|
async function call(service, path, as, body, query = '') {
|
|
56
56
|
const response = await service.handle(new Request(`${endpoint}${path}${query}`, {
|
|
@@ -145,6 +145,33 @@ export function runConversationAdapterContract(fixture) {
|
|
|
145
145
|
const events = (await call(b, `/${id}/events`, 'signedOut')).body.events;
|
|
146
146
|
assert.equal(events.filter(event => event.message?.id === reply.id).length, 1);
|
|
147
147
|
}));
|
|
148
|
+
test('adapter contract: reserved output IDs persist atomic reply and prompt receipts across handles', () => withFixture(async ({ store, second }) => {
|
|
149
|
+
const a = serviceOn(store, clock);
|
|
150
|
+
const id = (await call(a, '', 'signedOut', { message })).body.conversation_id;
|
|
151
|
+
const work = (await a.claim());
|
|
152
|
+
for (const outputId of ['__proto__', 'constructor', 'ordinary']) {
|
|
153
|
+
const b = serviceOn(second, clock);
|
|
154
|
+
const value = { ...reply, id: `reply_${outputId}` };
|
|
155
|
+
await a.reply(work, outputId, value);
|
|
156
|
+
await b.reply(work, outputId, value);
|
|
157
|
+
await assert.rejects(b.reply(work, outputId, { ...value, text: 'changed' }), { code: 'output_conflict' });
|
|
158
|
+
await a.requestAuthorization(work, `auth_${outputId}`, ['poppy:read']);
|
|
159
|
+
await b.requestAuthorization(work, `auth_${outputId}`, ['poppy:read']);
|
|
160
|
+
await a.requestUser(work, `user_${outputId}`, outputId);
|
|
161
|
+
await b.requestUser(work, `user_${outputId}`, outputId);
|
|
162
|
+
await assert.rejects(b.requestUser(work, `user_${outputId}`, 'changed'), { code: 'output_conflict' });
|
|
163
|
+
const persisted = await second.inspect();
|
|
164
|
+
const receipt = persisted.work.find(row => row.id === work.workId);
|
|
165
|
+
assert.ok(Object.hasOwn(receipt.outputs, outputId));
|
|
166
|
+
}
|
|
167
|
+
const events = (await call(serviceOn(second, clock), `/${id}/events`, 'signedOut')).body.events;
|
|
168
|
+
assert.equal(events.filter(event => event.message?.role === 'company').length, 3);
|
|
169
|
+
assert.equal(events.filter(event => event.type === 'authorization').length, 3);
|
|
170
|
+
assert.equal(events.filter(event => event.type === 'user_requested').length, 3);
|
|
171
|
+
clock.t += 1000;
|
|
172
|
+
await serviceOn(second, clock).claim();
|
|
173
|
+
await assert.rejects(a.reply(work, '__proto__', reply), { code: 'stale_work' });
|
|
174
|
+
}));
|
|
148
175
|
test('adapter contract: a waited POST committed before a crash is recovered unchanged on the other handle', () => withFixture(async ({ store, second }) => {
|
|
149
176
|
const crashing = serviceOn({ read: () => store.inspect(), transaction: (_selection, change) => store.adminTransaction(change).then(() => { throw new Error('crash after commit'); }) }, clock);
|
|
150
177
|
await assert.rejects(call(crashing, '', 'signedOut', { message }, '?wait=10'), { message: 'crash after commit' });
|
|
@@ -391,7 +391,7 @@ export function createConversationService(options) {
|
|
|
391
391
|
if (!streaming) {
|
|
392
392
|
const deadline = Date.now() + read.waitSeconds * 1000, logicalDeadline = clock.now() + read.waitSeconds * 1000;
|
|
393
393
|
while (!first.events.length && Date.now() < deadline && clock.now() < logicalDeadline && !signal.aborted) {
|
|
394
|
-
await wait(Math.min(pollIntervalMs, deadline - Date.now(), logicalDeadline - clock.now()), seen);
|
|
394
|
+
await wait(Math.min(pollIntervalMs, deadline - Date.now(), logicalDeadline - clock.now(), principal.tokenExpiresAt - clock.now()), seen);
|
|
395
395
|
seen = revision;
|
|
396
396
|
if (signal.aborted)
|
|
397
397
|
throw signal.reason;
|
|
@@ -401,12 +401,18 @@ export function createConversationService(options) {
|
|
|
401
401
|
cleanup();
|
|
402
402
|
return json(200, first);
|
|
403
403
|
}
|
|
404
|
-
|
|
405
|
-
|
|
404
|
+
const duration = Math.max(0, Math.min(streamMs, principal.tokenExpiresAt - clock.now()));
|
|
405
|
+
lifetime = setTimeout(cleanup, duration);
|
|
406
|
+
const encoder = new TextEncoder(), deadline = Date.now() + duration;
|
|
406
407
|
let pending = first, index = 0, bytes = 0, cancelled = false;
|
|
407
408
|
const stream = new ReadableStream({
|
|
408
409
|
async pull(target) {
|
|
409
410
|
try {
|
|
411
|
+
if (clock.now() >= principal.tokenExpiresAt) {
|
|
412
|
+
cleanup();
|
|
413
|
+
target.close();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
410
416
|
signal.throwIfAborted();
|
|
411
417
|
while (index >= pending.events.length) {
|
|
412
418
|
if (Date.now() >= deadline || pending.status === 'closed' && !pending.has_more) {
|
|
@@ -421,6 +427,7 @@ export function createConversationService(options) {
|
|
|
421
427
|
pending = await deliver();
|
|
422
428
|
index = 0;
|
|
423
429
|
}
|
|
430
|
+
await policy.withCurrent(principal, [], async () => { });
|
|
424
431
|
const event = pending.events[index++];
|
|
425
432
|
const frame = encoder.encode('id: ' + event.id + '\ndata: ' + JSON.stringify(event) + '\n\n');
|
|
426
433
|
bytes += frame.byteLength;
|
|
@@ -433,7 +440,7 @@ export function createConversationService(options) {
|
|
|
433
440
|
target.enqueue(frame);
|
|
434
441
|
}
|
|
435
442
|
catch (error) {
|
|
436
|
-
const expired = stop.signal.aborted && !request.signal.aborted;
|
|
443
|
+
const expired = (stop.signal.aborted || clock.now() >= principal.tokenExpiresAt) && !request.signal.aborted;
|
|
437
444
|
cleanup();
|
|
438
445
|
if (cancelled)
|
|
439
446
|
return;
|
|
@@ -570,7 +577,7 @@ export function createConversationService(options) {
|
|
|
570
577
|
function firstOutput(work, outputId, payload) {
|
|
571
578
|
const previous = own(work.outputs, outputId);
|
|
572
579
|
if (previous === undefined) {
|
|
573
|
-
work.outputs[outputId]
|
|
580
|
+
work.outputs = { ...work.outputs, [outputId]: payload };
|
|
574
581
|
return true;
|
|
575
582
|
}
|
|
576
583
|
if (previous !== payload)
|
package/dist/postgres.js
CHANGED
|
@@ -240,8 +240,8 @@ export class PostgresCompanyDatabase {
|
|
|
240
240
|
}
|
|
241
241
|
if (selection.kind === 'authorize') {
|
|
242
242
|
await get('signIns', clientRevisionKey(selection.clientId));
|
|
243
|
-
const pending = (await this.sqlGet("SELECT count(*) AS n FROM poppy_server.records WHERE ns=$1 AND kind='authorizations' AND ((body::jsonb#>>'{expiresAt}')::double precision)>$2", [namespace, selection.at]));
|
|
244
|
-
const client = (await this.sqlGet("SELECT count(*) AS n FROM poppy_server.records WHERE ns=$1 AND kind='authorizations' AND (body::jsonb#>>'{clientId}')=$2 AND ((body::jsonb#>>'{expiresAt}')::double precision)>$3", [namespace, selection.clientId, selection.at]));
|
|
243
|
+
const pending = (await this.sqlGet("SELECT count(*) AS n FROM poppy_server.records WHERE ns=$1 AND kind='authorizations' AND body::jsonb->'completion' IS NULL AND ((body::jsonb#>>'{expiresAt}')::double precision)>$2", [namespace, selection.at]));
|
|
244
|
+
const client = (await this.sqlGet("SELECT count(*) AS n FROM poppy_server.records WHERE ns=$1 AND kind='authorizations' AND body::jsonb->'completion' IS NULL AND (body::jsonb#>>'{clientId}')=$2 AND ((body::jsonb#>>'{expiresAt}')::double precision)>$3", [namespace, selection.clientId, selection.at]));
|
|
245
245
|
state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(pending.n), pendingForClient: Number(client.n) };
|
|
246
246
|
}
|
|
247
247
|
if (selection.kind === 'disconnect') {
|
|
@@ -293,8 +293,9 @@ export class PostgresCompanyDatabase {
|
|
|
293
293
|
throw new TypeError('cleanup limit');
|
|
294
294
|
return this.run(async () => {
|
|
295
295
|
await this.db.lock(JSON.stringify([namespace, 'issuer']));
|
|
296
|
-
const rows = await this.sqlAll(`SELECT kind,id FROM poppy_server.records 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)
|
|
296
|
+
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)
|
|
298
|
+
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]);
|
|
298
299
|
for (const row of rows)
|
|
299
300
|
await this.sqlRun("DELETE FROM poppy_server.records WHERE ns=$1 AND kind=$2 AND id=$3", [namespace, row.kind, row.id]);
|
|
300
301
|
return rows.length;
|
|
@@ -41,6 +41,10 @@ interface AuthorizationRecord {
|
|
|
41
41
|
browserHash: string;
|
|
42
42
|
expiresAt: number;
|
|
43
43
|
revision: number;
|
|
44
|
+
completion?: {
|
|
45
|
+
decisionHash: string;
|
|
46
|
+
callback: string;
|
|
47
|
+
};
|
|
44
48
|
}
|
|
45
49
|
interface CodeRecord {
|
|
46
50
|
clientId: string;
|
|
@@ -156,6 +160,11 @@ export interface ReferenceIssuerOptions {
|
|
|
156
160
|
dpop: boolean;
|
|
157
161
|
}[];
|
|
158
162
|
metadata: MetadataResolver;
|
|
163
|
+
/** Required for Direct Sign-In: synchronous authenticated encryption with a durable host-owned key. */
|
|
164
|
+
authorizationResultCipher?: {
|
|
165
|
+
seal(value: string): string;
|
|
166
|
+
open(value: string): string;
|
|
167
|
+
};
|
|
159
168
|
store: IssuerStore;
|
|
160
169
|
coordinator: AuthorityCoordinator;
|
|
161
170
|
now: () => number;
|
|
@@ -661,8 +661,7 @@ export function createReferenceIssuer(options) {
|
|
|
661
661
|
if (!scopes.every(item => lineage.scopes.includes(item)))
|
|
662
662
|
throw new TokenError('invalid_scope');
|
|
663
663
|
const successor = random(32);
|
|
664
|
-
//
|
|
665
|
-
delete draft.refreshTokens[lineage.previousHash];
|
|
664
|
+
// Retain ancestors until the fixed grant expiry so every replay can revoke its descendants.
|
|
666
665
|
lineage.previousHash = lineage.refreshHash;
|
|
667
666
|
lineage.refreshHash = tokenHash(successor);
|
|
668
667
|
draft.refreshTokens[lineage.refreshHash] = grantId;
|
|
@@ -702,9 +701,9 @@ export function createReferenceIssuer(options) {
|
|
|
702
701
|
const presented = tokenHash(refreshToken);
|
|
703
702
|
const grantId = own(draft.refreshTokens, presented);
|
|
704
703
|
const lineage = grantId === undefined ? undefined : own(draft.grants, grantId);
|
|
705
|
-
// Reuse of
|
|
704
|
+
// Reuse of any ancestor Account Token (e.g. a retry after a lost response) revokes the lineage, so its Sessions
|
|
706
705
|
// continue signed out instead of staying bound to a successor nobody holds (RFC 9700 4.14.2).
|
|
707
|
-
if (lineage && lineage.clientId === clientId && presented
|
|
706
|
+
if (lineage && lineage.clientId === clientId && presented !== lineage.refreshHash) {
|
|
708
707
|
revokeGrant(draft, grantId);
|
|
709
708
|
return null;
|
|
710
709
|
}
|
|
@@ -777,7 +776,7 @@ export function createReferenceIssuer(options) {
|
|
|
777
776
|
const state = await read({ kind: 'principal', sessionId: principal.sessionId, ...(principal.grantId ? { grantId: principal.grantId } : {}) });
|
|
778
777
|
const session = own(state.sessions, principal.sessionId);
|
|
779
778
|
if (principal.issuer !== issuer || principal.issuerGeneration !== state.generation || !session || session.clientId !== principal.clientId
|
|
780
|
-
|| session.expiresAt <= now() || session.userId !== principal.userId || !liveGrant(state, principal))
|
|
779
|
+
|| principal.tokenExpiresAt <= now() || session.expiresAt <= now() || session.userId !== principal.userId || !liveGrant(state, principal))
|
|
781
780
|
throw new AuthorityError('invalid_token');
|
|
782
781
|
requireScopes(principal.scopes, scopes);
|
|
783
782
|
return action();
|
|
@@ -858,6 +857,8 @@ export function createReferenceIssuer(options) {
|
|
|
858
857
|
// Validates the whole request before persisting anything; the host renders login and consent from the view.
|
|
859
858
|
// Unauthenticated: hosts must rate-limit their /authorize route; the pending cap only bounds state and coordinator time.
|
|
860
859
|
async beginAuthorization(request, browserBinding) {
|
|
860
|
+
if (!options.authorizationResultCipher)
|
|
861
|
+
throw new TypeError('authorizationResultCipher is required for Direct Sign-In');
|
|
861
862
|
if (typeof browserBinding !== 'string' || !browserBinding)
|
|
862
863
|
throw new TypeError('browserBinding');
|
|
863
864
|
const params = uniqueParams(new URL(request.url).searchParams, () => new AuthorizationError('invalid_request'));
|
|
@@ -905,7 +906,7 @@ export function createReferenceIssuer(options) {
|
|
|
905
906
|
const at = now();
|
|
906
907
|
prune(draft, at);
|
|
907
908
|
// shortcut: an unauthenticated caller can fill one client's slots for AUTHORIZATION_MS (10 minutes); hosts must rate-limit /authorize.
|
|
908
|
-
const pending = Object.values(draft.authorizations);
|
|
909
|
+
const pending = Object.values(draft.authorizations).filter(record => !record.completion);
|
|
909
910
|
if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(record => record.clientId === clientId).length) >= maxPendingPerClient)
|
|
910
911
|
throw fail('temporarily_unavailable');
|
|
911
912
|
const id = random(16);
|
|
@@ -914,8 +915,11 @@ export function createReferenceIssuer(options) {
|
|
|
914
915
|
return { id, clientId, clientName: client.client_name, scopes, expiresAt };
|
|
915
916
|
});
|
|
916
917
|
},
|
|
917
|
-
//
|
|
918
|
+
// Commit once; a bound identical decision can recover the encrypted callback without minting another code.
|
|
918
919
|
async completeAuthorization(id, browserBinding, decision) {
|
|
920
|
+
const cipher = options.authorizationResultCipher;
|
|
921
|
+
if (!cipher)
|
|
922
|
+
throw new TypeError('authorizationResultCipher is required for Direct Sign-In');
|
|
919
923
|
if (typeof id !== 'string' || typeof browserBinding !== 'string')
|
|
920
924
|
throw new AuthorizationError('invalid_request');
|
|
921
925
|
const snapshot = own((await read({ kind: 'authorization', id })).authorizations, id);
|
|
@@ -924,21 +928,32 @@ export function createReferenceIssuer(options) {
|
|
|
924
928
|
const record = own(draft.authorizations, id);
|
|
925
929
|
if (!record || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
|
|
926
930
|
throw new AuthorizationError('invalid_request');
|
|
927
|
-
const
|
|
928
|
-
if ('denied' in decision && decision.denied === true) {
|
|
929
|
-
delete draft.authorizations[id];
|
|
930
|
-
return callback(record.redirectUri, { error: 'access_denied', ...fields });
|
|
931
|
-
}
|
|
931
|
+
const denied = 'denied' in decision && decision.denied === true;
|
|
932
932
|
const { accountId, scopes } = decision;
|
|
933
933
|
// Partial consent narrows; the host may never grant more than was requested.
|
|
934
|
-
if (typeof accountId !== 'string' || !accountId || !Array.isArray(scopes) || !scopes.length || !scopes.every(scope => record.scopes.includes(scope)))
|
|
934
|
+
if (!denied && (typeof accountId !== 'string' || !accountId || !Array.isArray(scopes) || !scopes.length || !scopes.every(scope => record.scopes.includes(scope))))
|
|
935
935
|
throw new TypeError('decision');
|
|
936
|
-
if (!consentCurrent(draft, record.clientId, accountId, record.revision))
|
|
936
|
+
if (!denied && !consentCurrent(draft, record.clientId, accountId, record.revision))
|
|
937
937
|
throw new AuthorizationError('invalid_request');
|
|
938
|
-
|
|
938
|
+
const granted = denied ? [] : [...new Set(scopes)].sort();
|
|
939
|
+
const decisionHash = tokenHash(JSON.stringify(denied ? { denied: true } : { accountId, scopes: granted }));
|
|
940
|
+
if (record.completion) {
|
|
941
|
+
if (record.completion.decisionHash !== decisionHash)
|
|
942
|
+
throw new AuthorizationError('invalid_request');
|
|
943
|
+
return cipher.open(record.completion.callback);
|
|
944
|
+
}
|
|
945
|
+
const fields = { state: record.state, iss: issuer };
|
|
946
|
+
if (denied) {
|
|
947
|
+
const href = callback(record.redirectUri, { error: 'access_denied', ...fields });
|
|
948
|
+
record.completion = { decisionHash, callback: cipher.seal(href) };
|
|
949
|
+
return href;
|
|
950
|
+
}
|
|
939
951
|
const code = random(32);
|
|
940
|
-
|
|
941
|
-
|
|
952
|
+
record.expiresAt = Math.min(record.expiresAt, now() + CODE_MS);
|
|
953
|
+
draft.codes[tokenHash(code)] = { clientId: record.clientId, redirectUri: record.redirectUri, codeChallenge: record.codeChallenge, accountId: accountId, scopes: granted, expiresAt: record.expiresAt, grantId: null, revision: record.revision };
|
|
954
|
+
const href = callback(record.redirectUri, { code, ...fields });
|
|
955
|
+
record.completion = { decisionHash, callback: cipher.seal(href) };
|
|
956
|
+
return href;
|
|
942
957
|
});
|
|
943
958
|
return new URL(href);
|
|
944
959
|
},
|
package/dist/sqlite.js
CHANGED
|
@@ -307,8 +307,8 @@ export class SqliteCompanyDatabase {
|
|
|
307
307
|
}
|
|
308
308
|
if (selection.kind === 'authorize') {
|
|
309
309
|
get('signIns', clientRevisionKey(selection.clientId));
|
|
310
|
-
const pending = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.at);
|
|
311
|
-
const client = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.clientId')=? AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.clientId, selection.at);
|
|
310
|
+
const pending = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.completion') IS NULL AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.at);
|
|
311
|
+
const client = this.db.prepare("SELECT count(*) AS n FROM records WHERE ns=? AND kind='authorizations' AND json_extract(body,'$.completion') IS NULL AND json_extract(body,'$.clientId')=? AND json_extract(body,'$.expiresAt')>?").get(namespace, selection.clientId, selection.at);
|
|
312
312
|
state.limits = { sessions: 0, earliestSessionExpiry: 0, pending: Number(pending.n), pendingForClient: Number(client.n) };
|
|
313
313
|
}
|
|
314
314
|
if (selection.kind === 'disconnect') {
|
|
@@ -359,8 +359,9 @@ export class SqliteCompanyDatabase {
|
|
|
359
359
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
360
360
|
throw new TypeError('cleanup limit');
|
|
361
361
|
return this.run(async () => {
|
|
362
|
-
const rows = this.db.prepare(`SELECT kind,id FROM records WHERE ns=? AND
|
|
363
|
-
((kind IN ('authorizations','codes','accessTokens') AND json_extract(body,'$.expiresAt')<=?) OR (kind='signIns' AND json_extract(body,'$.expiresAt')+30000<?) OR (kind='replay' AND CAST(body AS REAL)<?)
|
|
362
|
+
const rows = this.db.prepare(`SELECT kind,id FROM records AS retained WHERE ns=? AND
|
|
363
|
+
((kind IN ('authorizations','codes','accessTokens') AND json_extract(body,'$.expiresAt')<=?) OR (kind='signIns' AND json_extract(body,'$.expiresAt')+30000<?) OR (kind='replay' AND CAST(body AS REAL)<?)
|
|
364
|
+
OR (kind='refreshTokens' AND NOT EXISTS (SELECT 1 FROM records AS lineage WHERE lineage.ns=retained.ns AND lineage.kind='grants' AND lineage.id=json_extract(retained.body,'$') AND json_extract(lineage.body,'$.expiresAt')>? AND json_extract(lineage.body,'$.revoked')=0))) LIMIT ?`).all(namespace, now, now, now, now, limit);
|
|
364
365
|
for (const row of rows)
|
|
365
366
|
this.db.prepare('DELETE FROM records WHERE ns=? AND kind=? AND id=?').run(namespace, row.kind, row.id);
|
|
366
367
|
return rows.length;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poppykit/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Personal Agent Protocol company SDK",
|
|
6
6
|
"repository": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"engines": {
|
|
12
|
-
"node": ">=24.
|
|
12
|
+
"node": ">=24.21.0 <25"
|
|
13
13
|
},
|
|
14
14
|
"packageManager": "pnpm@11.25.0",
|
|
15
15
|
"scripts": {
|