@poppykit/server 0.4.0 → 0.5.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.
Files changed (53) hide show
  1. package/API.md +10 -3
  2. package/AUTH.md +120 -124
  3. package/CHANGELOG.md +20 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +2 -0
  7. package/INTEGRATIONS.md +74 -81
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +11 -20
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +62 -17
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +79 -0
  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 -16
  22. package/dist/browser.js +65 -33
  23. package/dist/conversation/service.d.ts +11 -1
  24. package/dist/conversation/service.js +39 -9
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +5 -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 +31 -13
  32. package/dist/network/discovery.js +103 -66
  33. package/dist/operations.d.ts +115 -0
  34. package/dist/operations.js +212 -0
  35. package/dist/postgres-runtime.js +12 -4
  36. package/dist/postgres.d.ts +6 -0
  37. package/dist/postgres.js +72 -4
  38. package/dist/protocol/operations.d.ts +51 -0
  39. package/dist/protocol/operations.js +98 -0
  40. package/dist/protocol/session.d.ts +28 -0
  41. package/dist/protocol/session.js +93 -0
  42. package/dist/protocol/sse.d.ts +1 -0
  43. package/dist/protocol/sse.js +1 -1
  44. package/dist/protocol/wire.d.ts +8 -4
  45. package/dist/protocol/wire.js +37 -15
  46. package/dist/reference-auth/index.d.ts +10 -16
  47. package/dist/reference-auth/index.js +225 -430
  48. package/dist/sqlite.d.ts +11 -0
  49. package/dist/sqlite.js +84 -4
  50. package/examples/operations.mjs +18 -0
  51. package/examples/quickstart.mjs +8 -5
  52. package/migrations/RETIREMENT.md +77 -0
  53. package/package.json +5 -2
package/INTEGRATIONS.md CHANGED
@@ -1,94 +1,87 @@
1
1
  # API, browser and feedback integration
2
2
 
3
- Use the same authority policy and coordinator as protected conversation work.
4
- Supply the trusted public request URL; never derive it from arbitrary forwarding
5
- headers. Custom issuers and transports remain responsible for their documented
6
- revocation, address-validation and storage guarantees.
3
+ Use trusted configured public URLs, the resource Session authenticator and its
4
+ combined Session/account policy. Never derive public targets from arbitrary
5
+ forwarding headers. The host supplies framework routing and ingress limits.
7
6
 
8
- ## MCP authentication
7
+ ## APIs and MCP
9
8
 
10
- ```ts
11
- const metadata = mcpResourceMetadata({ resource: mcpUrl, issuer, dpop: true });
12
- const authenticator = createRequestAuthenticator({ authority, now: Date.now,
13
- endpoints: [{ origin: 'https://company.example', pathPrefix: '/mcp', resource: mcpUrl,
14
- mcp: { dpop: true, metadataUrl: 'https://company.example/.well-known/oauth-protected-resource/mcp' } }],
15
- });
16
- // Serve metadata publicly without credentials. In the existing MCP handler:
17
- try {
18
- const principal = await authenticator.authenticate(request);
19
- return await authority.policy.withCurrent(principal, ['tools:read'], () => handleMcp(request));
20
- } catch (error) {
21
- if (error instanceof AuthorityError) return mcpAuthorizationError(error, metadataUrl, true);
22
- throw error;
23
- }
24
- ```
9
+ Publish RFC 9728 metadata for a service that needs narrower tokens. Its resource
10
+ identifies that service; normal sign-in/refresh accepts the configured resource.
11
+ MCP resource metadata must exactly identify the advertised URL and name the
12
+ Company issuer when account access exists. `mcpResourceMetadata({resource,issuer?,
13
+ dpop})` supports issuerless resources too. Serve metadata without credentials.
14
+ RFC 9728 inserts the well-known path before the resource path and preserves a query.
15
+
16
+ Configure an MCP ResourceEndpoint with the resource's exact origin, pathPrefix,
17
+ resource and mcp: {dpop,metadataUrl}. Query-bearing resources are allowed, but the
18
+ whole request URL must match exactly: child paths or changed queries are rejected.
19
+ Bearer is MCP's baseline. DPoP is accepted only when advertised; introspection
20
+ returns dpopJkt:null only for an unbound token. Normal OAuth grants issue Bearer
21
+ MCP tokens without token exchange. A token for one service is refused elsewhere.
22
+
23
+ An ordinary MCP client without Poppy-Session can use its account access token.
24
+ Its Principal has no Poppy User or Session. A Personal Agent sends its Session
25
+ header on every API/MCP request; when present, the authenticator verifies it and
26
+ joins the token's client identity. Poppy Session state is independent of both older
27
+ MCP transport lifecycles and MCP 2026-07-28 per-request transport metadata. The SDK
28
+ does not implement JSON-RPC, initialization or Mcp-Session-Id management.
29
+
30
+ Catch AuthorityError and use mcpAuthorizationError(error,metadataUrl,dpop) for OAuth
31
+ challenges containing resource_metadata. invalid_session remains JSON/400 and rate
32
+ limits JSON/429 with Retry-After. Use sessions.policy.withCurrent for protected work.
25
33
 
26
- MCP metadata identifies the exact resource URL, issuer and ES256 support when
27
- enabled. Challenges include Bearer and, if supported, DPoP with `resource_metadata`.
28
- The exact endpoint accepts no child paths or added query. All other APIs retain
29
- DPoP-only authentication. Configure the reference issuer `mcpResources` with the
30
- same `{url,dpop}` list. Non-DPoP MCP targets require Token Exchange of a current
31
- DPoP Session Token, a matching client assertion and the Session Key proof. Other
32
- grants reject those targets with invalid_target; exchange rejects DPoP MCP targets.
33
- Exchange preserves the Session and narrows subject scopes without rotating an
34
- Account Token. Metadata advertises exchange when a Bearer-only MCP resource exists.
35
- DPoP MCP continues using normal DPoP token grants. Bearer tokens are
36
- resource-bound; DPoP-bound tokens cannot be presented as Bearer.
37
-
38
- An existing issuer can accept ordinary MCP OAuth clients through its own flow.
39
- Its introspector returns `dpopJkt: null` only for truly unbound Bearer credentials;
40
- ordinary MCP principals have empty `userId` and an account-bound session owned by
41
- the MCP client. Such sessions end on sign-in revocation/expiry and must not
42
- continue signed out. The external issuer must enforce this on every introspection
43
- and policy check and document its revocation visibility. The reference issuer
44
- implements personal-agent grants; it does not add a general OAuth login product.
45
-
46
- ## Browser sessions
34
+ ## Browser joining
47
35
 
48
36
  ```ts
49
- const browser = createBrowserSessions({ endpoint: browserEndpoint, domain: companyDomain,
50
- authority: referenceIssuer.browserSessions,
51
- policy: referenceIssuer.authority.policy, cookies: cookieStore, now: Date.now,
37
+ const browser = createBrowserSessions({
38
+ endpoint: browserEndpoint, domain: companyDomain, authenticator,
39
+ policy: sessions.policy, cookies: database.browserCookies(companyId), now: Date.now,
40
+ clearLogin: async (request, headers) => hostLogin.clear(request, headers),
52
41
  });
53
- // Mount browser.join on browserEndpoint; no request-body logging at this route.
54
- const result = await browser.withSession(request, ['records:read'], principal => readRecords(principal));
42
+ // GET/POST browserEndpoint -> browser.join
43
+ // POST browserEndpoint + '/authorize' -> browser.authorize (preserve endpoint query)
55
44
  ```
56
45
 
57
- `BrowserCookieStore.put/get` stores SHA256 cookie-value hashes, session bindings and
58
- expiry. Supply durable storage with targeted operations and expire old rows. Never
59
- store plaintext cookie values. The same store must implement `putJoin(codeHash,
60
- {cookieHash, expiresAt})` and atomic `consumeJoin(codeHash)`, which removes and
61
- returns the entry even if expired. Expire abandoned joins and rate-limit the public
62
- route in the host. Cookie/code hashes may be shared durably across handler replicas.
63
- `BrowserSessionAuthority.join(sessionKey)` resolves an existing active key; it never
64
- creates a Session. `current` reads current permissions. The reference issuer uses
65
- targeted key/Session selectors on its existing memory/SQLite/PostgreSQL stores.
66
-
67
- Mount both GET and POST. GET sets only a 60-second Strict __Host-poppy_join cookie
68
- and emits a random 256-bit hidden code, no-store, frame-ancestors none and same-origin
69
- opener isolation. POST consumes the submitted code first, including failed cookie,
70
- origin and proof checks. It requires that browser's cookie and a valid Session Key
71
- DPoP proof whose nonce is the code and which has no ath. Invalid requests return 400
72
- with no cookie; success clears the join cookie, sets the Session cookie and returns
73
- 303 to the endpoint's website root. No provider signing/JWKS assertion is involved. Defaults are `__Host-poppy-session`, Path=/, Secure,
74
- HttpOnly, SameSite=Lax and max 3600 seconds capped by the current session lifetime.
75
- SameSite=None is supported; Strict is refused. `authenticate` resolves current
76
- authority on every request. Use `withSession` for scoped mutations so permission
77
- checks and work are ordered by the same policy/coordinator. Sign-in, scope changes
78
- and revocation are reflected immediately; no permissions are cached in cookies.
79
- Host adapters must isolate the agent browser from the user's consent browser.
46
+ GET creates only a short-lived Strict join cookie and hidden random code, with
47
+ no-store, framing and opener protection. The trusted agent reads its own isolated
48
+ browser's code and separately POSTs JSON {code} to /authorize with Session/account
49
+ credentials. Success is 204. It then submits only code in the browser form. Never
50
+ accept a code from Company/model text, another browser or a user message.
51
+
52
+ Browser POST atomically consumes the code, including failed submissions, checks its
53
+ cookie/origin/site/expiry and prior authorization, and sets a new opaque cookie.
54
+ No token, JWT or proof is carried by the browser form or URL. Success clears the
55
+ join cookie and redirects 303 to the Company root. A signed-out join preserves an
56
+ existing human website login; a signed-in join requires clearLogin to invalidate
57
+ and clear host login, and replaces a previous protocol cookie.
58
+
59
+ BrowserCookieStore supplies put/get/remove plus putJoin/authorizeJoin/consumeJoin.
60
+ Stores persist only cookie/code hashes. authorizeJoin and consumeJoin serialize
61
+ one-use code decisions across replicas. Bundled SQLite/PostgreSQL implementations
62
+ are available through database.browserCookies(namespace). Rate-limit the public
63
+ GET/POST routes and run bounded expiry cleanup.
64
+
65
+ A browser cookie stores the authorization snapshot approved at join: same Session,
66
+ account and scopes until rejoin. It does not gain later scopes or sign-in authority.
67
+ Admission-token expiry alone does not end the approved cookie; Session expiry,
68
+ revocation and current account policy still apply. Use browser.withSession for
69
+ scoped work ordered by the same policy/coordinator. Defaults are Secure, HttpOnly,
70
+ Path=/ and SameSite=Lax, at most one hour capped by Session lifetime.
80
71
 
81
72
  ## Feedback
82
73
 
83
- `createFeedbackHandler({endpoint, authenticator, policy, accept, allow?})` validates
84
- a Session Token, including a signed-out token with no account scope. It checks the
85
- JSON/UTF8 byte budget and nonempty task/description limits (200/2000 Unicode
86
- codepoints), accepts unknown kinds and passes only a frozen inert-data report to
87
- `accept`. Do not interpret its text as instructions, including in later model use.
88
- The optional host `allow` hook supplies rate limiting; false returns 429. Malformed
89
- reports return 400, auth errors use the shared challenge, success is empty 202.
90
- There is no protocol deduplication identifier and no exactly-once promise. Hosts
91
- own report retention, privacy filtering and downstream side-effect handling.
92
-
93
- `tests/integrations.test.mts` exercises browser replay/current authority in memory
94
- and SQLite, MCP schemes/resources/ordinary-client integration and feedback.
74
+ createFeedbackHandler validates the Session before attributing a report and checks
75
+ current authority without account scopes. Reports require only task/description,
76
+ nonempty and at most 200/2000 Unicode codepoints, within a bounded JSON/UTF8 body.
77
+ Unknown fields are inert data. accept receives a frozen report and verified Principal;
78
+ never treat report text as instructions or put secrets/personal data into it.
79
+
80
+ The optional allow hook returning false yields 429 with Retry-After:60. Malformed
81
+ reports are 400; successful acceptance is 202 with no body. The client also supports
82
+ a Company's 200 alternative response. Hosts own report retention, privacy filtering
83
+ and downstream effect handling; feedback has no deduplication identifier.
84
+
85
+ ## Operations v2
86
+
87
+ [OPERATIONS.md](OPERATIONS.md) defines API403/Poppy-Approval, MCP structuredContent/_meta and conversation operation/approval integration. Publish support only after all interfaces in use have trusted consent and durable effect recovery. Ordinary MCP clients retain their OAuth flow; one unsupported tool returns a tool error. No v1 extension endpoint is added.
package/OPERATIONS.md ADDED
@@ -0,0 +1,142 @@
1
+ # Business operations (Operations v2)
2
+
3
+ Enable `extensions.operations: operationsV2` in Company discovery only after
4
+ installing these mechanics on every interface offering approval-gated actions.
5
+ There is no v1 operations endpoint. The SDK does not auto-advertise support.
6
+ `createBusinessOperations({companyId,metadata,policy,store,now?,retentionMs?,
7
+ maxRevisions?,effectTimeoutMs?})` verifies the client's exact v2 metadata before returning an
8
+ operation. Use the same verified Session/account policy and database coordinator
9
+ as request authentication. OAuth-only MCP identity cannot approve Poppy work.
10
+
11
+ The host chooses a stable URL-safe `operationId` for one business action, from
12
+ its own records. It must never derive another action identity from the submitted
13
+ approval, allocate a fresh action on retry, or reuse an ID for different work.
14
+ The service stores every issued, unexpired revision with its exact request.
15
+ A newer revision does not invalidate an older one. Summary/terms/expiry changes
16
+ always receive a fresh opaque revision. Default admission is 1000 live revisions
17
+ per action; overflow is 429/Retry-After, preserving issued revisions.
18
+
19
+ ## Propose and execute
20
+
21
+ `OperationInput` contains `operationId`, authenticated `principal`, required
22
+ `scopes`, `request`, optional parsed `approval`, and `proposal()`. The proposal
23
+ callback supplies the current `summary`, optional `terms`, and `expires_at`.
24
+ Describe every action condition, amount, recipient and date in the summary and
25
+ terms. Give the User at least 15 minutes where business conditions permit. Use
26
+ request/resource field names in structured terms, and document `approval_required`
27
+ in your OpenAPI responses. These business-semantic choices belong to the host.
28
+
29
+ Call `propose(input)` to publish terms without an effect, or route every action
30
+ through `local`/`external`: without a valid approval they return
31
+ `{kind:'approval_required',operation}` and execute nothing. Current authorization,
32
+ scopes and ownership precede receipt replay. Signed-out ownership includes the
33
+ exact Session instance/generation, not just a reused sid. Signed-in ownership
34
+ includes Company, client, User and account; another Session of that same identity
35
+ can approve. Signed-out access to account work receives an OAuth account challenge.
36
+ Foreign identities see the same `operation_not_found` error. Recheck current
37
+ policy before exposing protected results. Model text and Company conversation
38
+ text cannot create the approval field.
39
+
40
+ Bind requests with `captureApiOperationRequest(request)` (complete method/URL,
41
+ base64 exact body bytes), `{channel:'mcp',url,name,arguments}`, or
42
+ `{channel:'conversation',endpoint,conversationId}`. Use `parseApprovalHeader`,
43
+ `mcpApproval(params)` or the accepted conversation message's `approval`.
44
+ A mismatched/expired/unknown revision or wrong request performs nothing and
45
+ returns freshly generated terms, never the rejected approval's revision.
46
+ Execution is serialized by business action ID across all revisions and workers.
47
+
48
+ `OperationOutcome` is `approval_required`, `pending` or `completed` (result and
49
+ replay flag). `operationApiResponse(outcome,success?)` maps proposal to403 JSON and
50
+ pending to503/Retry-After. The default success is JSON200; pass a stable response
51
+ builder for your API's status/headers. Replay returns the first committed result
52
+ or an equivalent response. No approval goes in WWW-Authenticate.
53
+ `operationMcpResponse` produces the structured tool error or successful tool
54
+ result, and HTTP503/Retry-After for unresolved work. Pass a caught
55
+ `BusinessOperationError('extension_required')` to it for an ordinary tool error;
56
+ only refusing the entire MCP client uses the OAuth extension_required challenge.
57
+
58
+ ## Local and external effect contracts
59
+
60
+ `local(input,async approvedRevision => result)` executes only same-database
61
+ mutations. Use `database.withTransaction` inside the callback, on the SAME
62
+ `SqliteCompanyDatabase`/`PostgresCompanyDatabase` instance as the operation store
63
+ and authorization coordinator. The host mutation and operation receipt commit or
64
+ roll back together, including process death. Never put a network call, another
65
+ database or nontransactional filesystem effect in `local`. It receives the actual
66
+ approved revision: execute those exact terms, not a freshly recomputed quote.
67
+ SQLite supplies scoped `run/get`; PostgreSQL supplies a scoped query connection.
68
+ Handles reject use after their transaction ends.
69
+
70
+ `external(input,adapter)` commits an uncertain execution claim BEFORE invoking
71
+ `adapter.execute({key,operation})`. The key is derived from Company/action ID,
72
+ not revision or message ID. The host MUST guarantee durable idempotency for that
73
+ key in the external system, including concurrent calls and old paused workers.
74
+ It must preserve the approved terms and authenticate/authorize the action at its
75
+ submission boundary. Already accepted effects may finish during subsequent
76
+ revocation; revocation cannot retroactively undo an external system's commit.
77
+ Arbitrary network effects are not exactly-once, and a local receipt alone cannot
78
+ make them so. Do not use this API with a downstream lacking this contract.
79
+
80
+ Execution/reconciliation has a default30s deadline (`effectTimeoutMs`). It returns pending on a deadline, without cancelling or freeing a possibly running effect. A timeout/throw/process death leaves the claim uncertain indefinitely. Retrying
81
+ returns pending and never calls execute again automatically. Lease expiration,
82
+ new terms, process restart and receipt cleanup cannot release the claim.
83
+ `reconcile(input,adapter)` asks the host's durable system by the same key:
84
+
85
+ - `pending`: remain fenced, answer503 with Retry-After.
86
+ - `completed,result`: record the actual outcome and replay it.
87
+ - `absent`: assert the old worker cannot later commit outside that idempotency
88
+ fence; the SDK may submit the same key and original approved revision again.
89
+
90
+ Reconciliation itself must be safe concurrently with execute and other
91
+ reconcilers. The first committed result is immutable. Preserve a meaningful
92
+ business failure as an equivalent terminal outcome when definitively known;
93
+ never describe unknown effect state as failure. Hosts enforce ingress quotas,
94
+ worker deadlines and external reconciliation scheduling. A deadline must return
95
+ pending without deleting the durable claim, even if its worker is still alive.
96
+
97
+ ## Conversations
98
+
99
+ Configure `createConversationService({operations:metadata,...})` only on an
100
+ endpoint with complete v2 execution. It refuses unsupported clients with the
101
+ extension challenge. It accepts approval-only user-side messages and operation-only
102
+ Company messages, gated by current client metadata on requests/publication.
103
+ `operationContext(work)` provides the accepted principal, conversation binding
104
+ and only the actual message approval. Use that context with the business service,
105
+ then `reply` with `operation` when proposing or the actual business outcome after
106
+ execution. A conversation's lease/message receipts do not deduplicate effects.
107
+ Repeated approval in a new message adds no event or work; same message-ID retries
108
+ retain the ordinary original receipt. Cross-conversation approval fails binding.
109
+
110
+ ## Store contract, retention and migration
111
+
112
+ `database.businessOperations(companyNamespace)` implements
113
+ `BusinessOperationStore`: an asynchronous, serializable transaction on one action
114
+ slot, including absent-row serialization across processes. Local host effects
115
+ and policy checks must enlist in the same coordinator. Exceptions roll back the
116
+ slot and effects even inside an enclosing transaction. Snapshots/results detach;
117
+ unknown record versions and changed IDs are refused. Run
118
+ `runBusinessOperationAdapterContract` from the adapter-contract subpath for a
119
+ custom store, plus your engine's process-death/effect-enlistment tests.
120
+
121
+ Business record version1 uses the existing SQLite/PostgreSQL `records` table,
122
+ kind `business_operations`. No applied SQL migration is rewritten, no old writer
123
+ can overwrite this kind through existing targeted auth/conversation selectors,
124
+ and no credentials or user rows are removed. Authentication lifecycle records
125
+ remain separate. Stop incompatible future writers before any version transition.
126
+
127
+ Each execution retains its first protected result at least through the approved
128
+ revision's expiry (default also24h after claim). `prune(operationId)` only drops a
129
+ completed result and terms strictly AFTER retention. It keeps a minimal owner/
130
+ action tombstone permanently; later attempts can get fresh terms but cannot
131
+ execute that business action again. Uncertain claims/terms never prune. If you
132
+ need to retire tombstones, an authoritative permanent business fence must still
133
+ prevent re-execution; deleting the namespace is not retention. Protect stored
134
+ terms/results with your database access/encryption policy. Approval attribution
135
+ records the owner, approved revision and claim time; this states what the agent
136
+ asserted, not proof of what a human saw.
137
+
138
+ `node examples/operations.mjs --self-check` uses only synthetic identities and a
139
+ private temporary SQLite file. Real hosts use the request authenticator and
140
+ combined Session/account policy, not the example's test policy. Process-crash,
141
+ concurrent-process, independent wire and PostgreSQL acceptance is in
142
+ `tests/operations.test.mts`.
package/README.md CHANGED
@@ -1,24 +1,10 @@
1
1
  # PoppyKit server
2
2
 
3
- Draft 0.3 migration (2026-10-28), pinned to the public source captured that day.
4
- Client/CLI 0.5.0 and server 0.4.0 break the earlier protocol contracts. Stop old
5
- writers and follow the documented storage/reset procedure before compatible
6
- writers resume. Registry, deployment and live acceptance have separate receipts.
7
-
8
- 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`.
9
-
10
3
  TypeScript SDK for companies implementing [Personal Agent Protocol draft 0.3](https://personalagentprotocol.org/docs/0.3/spec).
11
- Verify requests, serve conversations and enforce current authority. Your company
12
- supplies accounts, consent UI, agent, business tools and HTTPS routing. [MIT](LICENSE).
4
+ Verify requests and serve conversations using your accounts, agent and business tools. [MIT](LICENSE).
13
5
 
14
6
  ## Install
15
7
 
16
- Use the exact version verified by its protected release workflow; check registry publication before installation.
17
-
18
- Requires Node **24.x, at least 24.21.0**, and ESM. CI tests Node 24.21.0 on Linux, macOS and Windows;
19
- other Node majors are unsupported. SQLite is optional/experimental and
20
- requires local filesystem locking.
21
-
22
8
  ```sh
23
9
  npm install @poppykit/server
24
10
  ```
@@ -31,7 +17,7 @@ in production. The reference issuer is optional; [existing issuers](AUTH.md) use
31
17
  an ExternalAuthorityAdapter instead.
32
18
 
33
19
  ```js
34
- import { createRequestAuthenticator, createConversationService } from '@poppykit/server';
20
+ import { createRequestAuthenticator, createConversationService, createSessionAuthority } from '@poppykit/server';
35
21
  import { createMetadataResolver, createNodeTransport, createReferenceIssuer } from '@poppykit/server/reference-auth';
36
22
  import { SqliteCompanyDatabase } from '@poppykit/server/sqlite';
37
23
 
@@ -40,21 +26,24 @@ const transport = createNodeTransport();
40
26
  try {
41
27
  const issuer = 'https://company.example/issuer';
42
28
  const endpoint = 'https://company.example/conversations';
29
+ const metadata = createMetadataResolver(transport);
43
30
  const auth = createReferenceIssuer({
44
31
  issuer, tokenEndpoint: issuer + '/token', revocationEndpoint: issuer + '/revoke',
45
32
  protectedResources: ['https://company.example'], supportedScopes: [], resources: [endpoint],
46
- metadata: createMetadataResolver(transport), store: database.issuer(issuer),
33
+ metadata, store: database.issuer(issuer),
47
34
  coordinator: database, now: Date.now,
48
35
  });
36
+ const sessions = createSessionAuthority({companyId:issuer,metadata,store:database.sessions(issuer),
37
+ coordinator:database,accountPolicy:auth.authority.policy,now:Date.now});
49
38
  const service = createConversationService({
50
39
  historyScopes: ['orders:read'],
51
- endpoint, store: database.conversations(issuer), policy: auth.authority.policy,
52
- authenticator: createRequestAuthenticator({ authority: auth.authority,
40
+ endpoint, store: database.conversations(issuer), policy: sessions.policy,
41
+ authenticator: createRequestAuthenticator({ sessions, authority: auth.authority,
53
42
  endpoints: [{ origin: 'https://company.example', pathPrefix: '/conversations', resource: endpoint }],
54
43
  now: Date.now }), clock: { now: Date.now },
55
44
  });
56
45
  console.log((await service.handle(new Request(endpoint, { method: 'POST',
57
- headers: { 'content-type': 'application/json' }, body: '{}' }))).status); // 401
46
+ headers: { 'content-type': 'application/json' }, body: '{}' }))).status); // 400
58
47
  } finally {
59
48
  transport.close?.();
60
49
  database.close();
@@ -63,6 +52,8 @@ try {
63
52
 
64
53
  ## Guides
65
54
 
55
+ - [Business operations](OPERATIONS.md): exact per-operation approval, durable retries and effect contracts.
56
+
66
57
  - [Authentication](AUTH.md): external/reference issuers, consent and revocation.
67
58
  - [Storage](STORAGE.md): shared authority coordinator, cross-process transactions and reset.
68
59
  - [Delivery](DELIVERY.md): workers, streaming, cursors, retention and effect idempotency.
package/RELEASING.md CHANGED
@@ -89,3 +89,5 @@ certify a production keyring, physical terminal, browser engine, KMS or TLS depl
89
89
  Windows GNU tar is given archive basename plus directory cwd to avoid drive-colon
90
90
  remote parsing. Failure logs retain seven days. Expired/missing artifacts require a
91
91
  new full run, not relabeling old results. See [CONTRIBUTING.md](CONTRIBUTING.md).
92
+
93
+ The local Operations SDK checkpoint does not satisfy release acceptance. Complete the block3 CLI business-approval experience and stable all-tests/package/native gates before publication. Operations metadata must remain opt-in to fully configured hosts. Include OPERATIONS.md and both operation examples in candidate archives.
package/SECURITY.md CHANGED
@@ -11,3 +11,5 @@ impact and the trust boundary involved. These draft packages have no promised
11
11
  support window or response SLA. Custom vaults, issuers, browser controllers,
12
12
  proxy routing and deployment policy remain operator responsibilities. Redaction
13
13
  and scoped authority do not isolate an unrestricted same-user program.
14
+
15
+ Business-operation approval requires the trusted per-operation UI described in [OPERATIONS.md](OPERATIONS.md). Keep Company/model text separate from user consent. Local effects must share the receipt transaction; arbitrary network effects require host idempotency and reconciliation. Retain uncertain claims until the actual outcome is known.
package/STORAGE.md CHANGED
@@ -3,7 +3,8 @@
3
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
- implement `ConversationStore`, `IssuerStore` and `AuthorityCoordinator` themselves.
6
+ implement `ConversationStore`, `IssuerStore`, `SessionStore`, `BrowserCookieStore`
7
+ and `AuthorityCoordinator` themselves.
7
8
  The tested Node 24.21.0 runtime marks `node:sqlite` experimental.
8
9
 
9
10
  ```ts
@@ -71,7 +72,8 @@ its own idempotency key (such as the stable work ID), or the application must re
71
72
  an uncertain result for reconciliation. Never claim universal exactly-once execution.
72
73
 
73
74
  `cleanup(namespace, now, limit)` bounds deletion to at most 1000 expired short-lived
74
- codes, authorizations, access tokens, replay entries or expired/revoked/orphaned
75
+ codes, authorizations, access tokens, browser cookies/join codes, expired resource
76
+ Sessions, replay entries or expired/revoked/orphaned
75
77
  Account Token hashes. Every used hash for a live lineage remains replay-detectable
76
78
  through its fixed expiry; retained size follows rotation volume. It does not delete grants,
77
79
  work intent, message receipts or conversation events. Receipt identity remains
@@ -81,21 +83,23 @@ force another conversation's receipts out of storage.
81
83
 
82
84
  ## Non-durable use and resets
83
85
 
84
- Normalized issuer state version 3 upgrades to version 4 once, under the existing
85
- SQLite writer/PostgreSQL namespace lock. Stop old writers before using the new
86
- adapter. No SQL schema change is needed: `sessionKeys` records use the existing
87
- namespace/kind/ID primary key. Token selectors load that mapping and its internal
88
- Session ID in the same transaction as creation, ownership checks and rotation.
89
- Mappings remain indefinitely after a Session ends, preventing key reuse.
90
-
91
- Old keys could belong to several Sessions, so the upgrade ends old Sessions and
92
- tombstones every known old access-token key rather than guessing a mapping.
93
- Internal Session IDs, sticky account records, stable Users, grants, all retained
94
- Account Token hashes, revocation fences and accepted-work authority remain.
95
- Legacy pending authorization/code/Device/Mediated records are retired: restart
96
- sign-in rather than accepting consent without the new binding. Existing compatible
97
- Account Tokens can create fresh-key Sessions. Unsupported earlier state versions
98
- are refused. The upgrade scans a namespace once; normal selectors stay targeted.
86
+ Normalized issuer state versions 3 and 4 upgrade to version 5 once, under the
87
+ existing SQLite writer/PostgreSQL namespace lock. Stop old writers before opening
88
+ the new adapters; their version checks reject the upgraded namespace. Applied SQL
89
+ migration history is unchanged: this is a JSON record-format transition.
90
+
91
+ The upgrade preserves the generation, Sessions and key mappings, grants and every
92
+ retained refresh-token hash, pending authorizations/codes, verifying sign-in hooks,
93
+ replay fences and nonces. It does not expire Sessions or clear pending consent.
94
+ Access-token records gain client/account identity only where an existing grant or
95
+ Session proves it; conflicting or missing associations remain unresolved. Unknown
96
+ earlier versions fail closed. Custom issuer adapters must upgrade the complete
97
+ namespace under their writer lock before normal targeted reads.
98
+
99
+ OAuth account access is independent of resource Sessions. The record upgrade does
100
+ not replay or reinterpret old pending requests. Keep uncertain
101
+ work and encrypted delivery references for reconciliation; do not reset a store or
102
+ reinterpret an unresolved record as a fresh request.
99
103
 
100
104
  `new SqliteCompanyDatabase(':memory:')` is an explicit non-durable adapter for tests
101
105
  and development. Each instance is separate, is lost at close/process death, and
@@ -196,3 +200,44 @@ never a production migration command.
196
200
  `runIssuerAdapterContract` joins the existing reusable conversation contract in
197
201
  `@poppykit/server/adapter-contract`. Run both against two fresh shared handles;
198
202
  process death, lock ordering and DB durability still require backend-specific tests.
203
+
204
+
205
+ ## Resource Sessions and browser cookies
206
+
207
+ Use database.sessions(companyId) and database.browserCookies(companyId), also for
208
+ issuerless Companies. Resource Session records and their namespace generation are
209
+ separate from legacy issuer Sessions and account grants. No applied SQL history is
210
+ rewritten; these records use the existing namespaced JSON table and indexes.
211
+
212
+ SessionStore.read(clientId,sid) returns version, generation and the stored record.
213
+ transaction(clientId,sid,at,change) must serialize admission across all sids for that
214
+ client, including absent records, and supply SessionAdmission.activeCount and
215
+ earliestExpiry for non-ended records with expiresAt > at. Counts and record commit
216
+ are one transaction. Callbacks are synchronous/detached; rollback and binding checks
217
+ apply. PostgreSQL takes client-before-sid advisory locks, SQLite its writer lock.
218
+ The default authority cap is 10,000 live Sessions per client, lifetime one day.
219
+ Expired records may be explicitly forgotten by bounded cleanup; a recreated sid
220
+ has a fresh instanceId, distinct from its prior resource Session. Operations v2
221
+ must preserve this distinction when defining signed-out approval ownership.
222
+
223
+ BrowserCookieStore stores opaque hashes and immutable approved Principal snapshots.
224
+ authorizeJoin(codeHash,principal) conditionally authorizes one live join;
225
+ consumeJoin atomically removes/returns it, including expired entries. remove deletes
226
+ a superseded browser-cookie hash. The host must implement these atomic operations
227
+ across replicas and use current account/Session policy when serving cookies.
228
+ Uncertain mediated verifying records are excluded from expiry cleanup and remain
229
+ in pending capacity counts until host reconciliation; never delete them to free quota.
230
+
231
+ Custom Session/browser adapters can run runSessionAdapterContract and
232
+ runBrowserCookieAdapterContract from @poppykit/server/adapter-contract against two
233
+ shared handles. See [legacy retirement](migrations/RETIREMENT.md) before any deletion.
234
+
235
+ ## Business operation persistence
236
+
237
+ [Operations storage contracts](OPERATIONS.md#store-contract-retention-and-migration) are separate from authentication lifecycle operations and message receipts. BusinessOperationStore uses an asynchronous transaction for same-database effects, unlike synchronous issuer/conversation callbacks. SQLite and PostgreSQL use the separate business_operations record kind. Use the same company database instance for policy, operation storage and local effects. No existing SQL history, credentials or uncertain work is deleted. Unknown versions fail closed. External effects require a durable downstream idempotency/reconciliation contract. Never prune or release an uncertain business claim after a worker deadline.
238
+
239
+ Postgres adapters handle socket errors while they own a checked-out connection.
240
+ Connection loss invalidates the transaction, rejects the operation and discards
241
+ the broken connection. A successful retry must reconcile durable state first;
242
+ a lost COMMIT response does not prove rollback. Callers still own their pool and
243
+ its idle `error` handler, and host-enlisted connection lifetimes.
@@ -7,6 +7,18 @@ export type IssuerAdapterFixture = () => Promise<{
7
7
  }>;
8
8
  /** A fresh shared namespace, including absent-row serialization; no vendor-specific fixture is required. */
9
9
  export declare function runIssuerAdapterContract(fixture: IssuerAdapterFixture): void;
10
+ export type SessionAdapterFixture = () => Promise<{
11
+ store: import('./auth/session.js').SessionStore;
12
+ second: import('./auth/session.js').SessionStore;
13
+ close(): Promise<void>;
14
+ }>;
15
+ export declare function runSessionAdapterContract(fixture: SessionAdapterFixture): void;
16
+ export type BrowserCookieAdapterFixture = () => Promise<{
17
+ store: import('./browser.js').BrowserCookieStore;
18
+ second: import('./browser.js').BrowserCookieStore;
19
+ close(): Promise<void>;
20
+ }>;
21
+ export declare function runBrowserCookieAdapterContract(fixture: BrowserCookieAdapterFixture): void;
10
22
  type Store = ConversationOptions['store'] & {
11
23
  inspect(): Promise<import('./conversation/service.js').ConversationStoreState>;
12
24
  adminTransaction<T>(change: (draft: import('./conversation/service.js').ConversationStoreState) => T): Promise<T>;
@@ -17,4 +29,10 @@ export type ConversationAdapterFixture = () => Promise<{
17
29
  close(): Promise<void>;
18
30
  }>;
19
31
  export declare function runConversationAdapterContract(fixture: ConversationAdapterFixture): void;
32
+ export type BusinessOperationAdapterFixture = () => Promise<{
33
+ store: import('./operations.js').BusinessOperationStore;
34
+ second: import('./operations.js').BusinessOperationStore;
35
+ close(): Promise<void>;
36
+ }>;
37
+ export declare function runBusinessOperationAdapterContract(fixture: BusinessOperationAdapterFixture): void;
20
38
  export {};