@poppykit/server 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/API.md +11 -3
  2. package/AUTH.md +120 -95
  3. package/CHANGELOG.md +18 -0
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +13 -10
  6. package/DELIVERY.md +21 -1
  7. package/INTEGRATIONS.md +72 -69
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +14 -17
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +57 -2
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +90 -11
  15. package/dist/auth/authenticator.d.ts +3 -1
  16. package/dist/auth/authenticator.js +34 -29
  17. package/dist/auth/authority.d.ts +8 -2
  18. package/dist/auth/authority.js +7 -2
  19. package/dist/auth/session.d.ts +47 -0
  20. package/dist/auth/session.js +94 -0
  21. package/dist/browser.d.ts +15 -13
  22. package/dist/browser.js +80 -46
  23. package/dist/conversation/service.d.ts +18 -8
  24. package/dist/conversation/service.js +60 -28
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +6 -1
  28. package/dist/index.js +3 -0
  29. package/dist/mcp.d.ts +1 -1
  30. package/dist/mcp.js +8 -4
  31. package/dist/network/discovery.d.ts +51 -22
  32. package/dist/network/discovery.js +159 -69
  33. package/dist/network/redirect.d.ts +3 -0
  34. package/dist/network/redirect.js +38 -0
  35. package/dist/network/tasks.d.ts +23 -0
  36. package/dist/network/tasks.js +45 -0
  37. package/dist/operations.d.ts +115 -0
  38. package/dist/operations.js +212 -0
  39. package/dist/postgres.d.ts +6 -0
  40. package/dist/postgres.js +92 -6
  41. package/dist/protocol/operations.d.ts +51 -0
  42. package/dist/protocol/operations.js +98 -0
  43. package/dist/protocol/session.d.ts +28 -0
  44. package/dist/protocol/session.js +93 -0
  45. package/dist/protocol/sse.d.ts +1 -0
  46. package/dist/protocol/sse.js +1 -1
  47. package/dist/protocol/wire.d.ts +20 -15
  48. package/dist/protocol/wire.js +70 -22
  49. package/dist/reference-auth/index.d.ts +17 -12
  50. package/dist/reference-auth/index.js +252 -386
  51. package/dist/reference-auth/store.d.ts +2 -1
  52. package/dist/sqlite.d.ts +11 -0
  53. package/dist/sqlite.js +104 -6
  54. package/examples/operations.mjs +18 -0
  55. package/examples/quickstart.mjs +10 -6
  56. package/migrations/RETIREMENT.md +77 -0
  57. package/package.json +5 -2
package/INTEGRATIONS.md CHANGED
@@ -1,84 +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.
25
15
 
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: every grant chooses Bearer for a non-DPoP MCP server even
31
- when a proof was provided. DPoP MCP requires a valid proof. Bearer tokens are
32
- resource-bound; DPoP-bound tokens cannot be presented as Bearer.
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.
33
22
 
34
- An existing issuer can accept ordinary MCP OAuth clients through its own flow.
35
- Its introspector returns `dpopJkt: null` only for truly unbound Bearer credentials;
36
- ordinary MCP principals have empty `userId` and an account-bound session owned by
37
- the MCP client. Such sessions end on sign-in revocation/expiry and must not
38
- continue signed out. The external issuer must enforce this on every introspection
39
- and policy check and document its revocation visibility. The reference issuer
40
- implements personal-agent grants; it does not add a general OAuth login product.
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.
41
29
 
42
- ## Browser sessions
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.
33
+
34
+ ## Browser joining
43
35
 
44
36
  ```ts
45
- const browser = createBrowserSessions({ endpoint: browserEndpoint, domain: companyDomain,
46
- metadata, authority: referenceIssuer.browserSessions,
47
- 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),
48
41
  });
49
- // Mount browser.join on browserEndpoint; no request-body logging at this route.
50
- 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)
51
44
  ```
52
45
 
53
- `BrowserCookieStore.put/get` stores SHA256 cookie-value hashes, session bindings and
54
- expiry. Supply durable storage with targeted operations and expire old rows. Never
55
- store plaintext cookie values. `BrowserSessionAuthority.join` must atomically
56
- consume assertion jti and validate an active same-client/same-user session; `current`
57
- must read current session permissions. The reference issuer implements both on its
58
- existing durable issuer store/coordinator; SQLite uses targeted browser selectors.
59
-
60
- The browser handler verifies JWKS signature, asymmetric algorithm, typ, exact aud,
61
- times/lifetime, replay, client/user/session and HTTPS company return URL. Invalid
62
- requests return 400 with no cookie; successful requests set a host-only cookie and
63
- return 303 to the validated URL. Defaults are `__Host-poppy-session`, Path=/, Secure,
64
- HttpOnly, SameSite=Lax and max 3600 seconds capped by the current session lifetime.
65
- SameSite=None is supported; Strict is refused. `authenticate` resolves current
66
- authority on every request. Use `withSession` for scoped mutations so permission
67
- checks and work are ordered by the same policy/coordinator. Sign-in, scope changes
68
- and revocation are reflected immediately; no permissions are cached in cookies.
69
- 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.
70
71
 
71
72
  ## Feedback
72
73
 
73
- `createFeedbackHandler({endpoint, authenticator, policy, accept, allow?})` validates
74
- a Session Token, including a signed-out token with no account scope. It checks the
75
- JSON/UTF8 byte budget and nonempty task/description limits (200/2000 Unicode
76
- codepoints), accepts unknown kinds and passes only a frozen inert-data report to
77
- `accept`. Do not interpret its text as instructions, including in later model use.
78
- The optional host `allow` hook supplies rate limiting; false returns 429. Malformed
79
- reports return 400, auth errors use the shared challenge, success is empty 202.
80
- There is no protocol deduplication identifier and no exactly-once promise. Hosts
81
- own report retention, privacy filtering and downstream side-effect handling.
82
-
83
- `tests/integrations.test.mts` exercises browser replay/current authority in memory
84
- 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,19 +1,10 @@
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
-
5
- TypeScript SDK for companies implementing [Personal Agent Protocol draft 0.2](https://personalagentprotocol.org/docs/0.2/spec).
6
- Verify requests, serve conversations and enforce current authority. Your company
7
- supplies accounts, consent UI, agent, business tools and HTTPS routing. [MIT](LICENSE).
3
+ TypeScript SDK for companies implementing [Personal Agent Protocol draft 0.3](https://personalagentprotocol.org/docs/0.3/spec).
4
+ Verify requests and serve conversations using your accounts, agent and business tools. [MIT](LICENSE).
8
5
 
9
6
  ## Install
10
7
 
11
- This source contains unreleased repairs; final version selection and publication are pending.
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
15
- requires local filesystem locking.
16
-
17
8
  ```sh
18
9
  npm install @poppykit/server
19
10
  ```
@@ -26,7 +17,7 @@ in production. The reference issuer is optional; [existing issuers](AUTH.md) use
26
17
  an ExternalAuthorityAdapter instead.
27
18
 
28
19
  ```js
29
- import { createRequestAuthenticator, createConversationService } from '@poppykit/server';
20
+ import { createRequestAuthenticator, createConversationService, createSessionAuthority } from '@poppykit/server';
30
21
  import { createMetadataResolver, createNodeTransport, createReferenceIssuer } from '@poppykit/server/reference-auth';
31
22
  import { SqliteCompanyDatabase } from '@poppykit/server/sqlite';
32
23
 
@@ -35,20 +26,24 @@ const transport = createNodeTransport();
35
26
  try {
36
27
  const issuer = 'https://company.example/issuer';
37
28
  const endpoint = 'https://company.example/conversations';
29
+ const metadata = createMetadataResolver(transport);
38
30
  const auth = createReferenceIssuer({
39
31
  issuer, tokenEndpoint: issuer + '/token', revocationEndpoint: issuer + '/revoke',
40
- poppyDomains: ['company.example'], directScopes: [], resources: [endpoint],
41
- metadata: createMetadataResolver(transport), store: database.issuer(issuer),
32
+ protectedResources: ['https://company.example'], supportedScopes: [], resources: [endpoint],
33
+ metadata, store: database.issuer(issuer),
42
34
  coordinator: database, now: Date.now,
43
35
  });
36
+ const sessions = createSessionAuthority({companyId:issuer,metadata,store:database.sessions(issuer),
37
+ coordinator:database,accountPolicy:auth.authority.policy,now:Date.now});
44
38
  const service = createConversationService({
45
- endpoint, store: database.conversations(issuer), policy: auth.authority.policy,
46
- authenticator: createRequestAuthenticator({ authority: auth.authority,
39
+ historyScopes: ['orders:read'],
40
+ endpoint, store: database.conversations(issuer), policy: sessions.policy,
41
+ authenticator: createRequestAuthenticator({ sessions, authority: auth.authority,
47
42
  endpoints: [{ origin: 'https://company.example', pathPrefix: '/conversations', resource: endpoint }],
48
43
  now: Date.now }), clock: { now: Date.now },
49
44
  });
50
45
  console.log((await service.handle(new Request(endpoint, { method: 'POST',
51
- headers: { 'content-type': 'application/json' }, body: '{}' }))).status); // 401
46
+ headers: { 'content-type': 'application/json' }, body: '{}' }))).status); // 400
52
47
  } finally {
53
48
  transport.close?.();
54
49
  database.close();
@@ -57,6 +52,8 @@ try {
57
52
 
58
53
  ## Guides
59
54
 
55
+ - [Business operations](OPERATIONS.md): exact per-operation approval, durable retries and effect contracts.
56
+
60
57
  - [Authentication](AUTH.md): external/reference issuers, consent and revocation.
61
58
  - [Storage](STORAGE.md): shared authority coordinator, cross-process transactions and reset.
62
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,6 +83,24 @@ force another conversation's receipts out of storage.
81
83
 
82
84
  ## Non-durable use and resets
83
85
 
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.
103
+
84
104
  `new SqliteCompanyDatabase(':memory:')` is an explicit non-durable adapter for tests
85
105
  and development. Each instance is separate, is lost at close/process death, and
86
106
  offers no cross-process persistence. The test fixture's object store is also
@@ -180,3 +200,38 @@ never a production migration command.
180
200
  `runIssuerAdapterContract` joins the existing reusable conversation contract in
181
201
  `@poppykit/server/adapter-contract`. Run both against two fresh shared handles;
182
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.
@@ -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 {};