@collective-intelligence/external-apps-server-sdk 0.0.0-stage → 0.1.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 (40) hide show
  1. package/README.md +1075 -2
  2. package/dist/admin.d.ts +7556 -0
  3. package/dist/admin.js +6 -0
  4. package/dist/auth/better-auth.d.ts +71 -0
  5. package/dist/auth/better-auth.js +1 -0
  6. package/dist/auth/federated.d.ts +135 -0
  7. package/dist/auth/federated.js +8 -0
  8. package/dist/auth.d.ts +162 -0
  9. package/dist/auth.js +1 -0
  10. package/dist/express.d.ts +644 -0
  11. package/dist/express.js +1 -0
  12. package/dist/gateway/contracts.d.ts +663 -0
  13. package/dist/gateway/contracts.js +0 -0
  14. package/dist/gateway.d.ts +762 -0
  15. package/dist/gateway.js +8 -0
  16. package/dist/index.d.ts +597 -0
  17. package/dist/index.js +9 -0
  18. package/dist/knowledge-sources/payload.d.ts +420 -0
  19. package/dist/knowledge-sources/payload.js +1 -0
  20. package/dist/knowledge-sources/strapi.d.ts +389 -0
  21. package/dist/knowledge-sources/strapi.js +1 -0
  22. package/dist/persistence/drizzle-effect.d.ts +38 -0
  23. package/dist/persistence/drizzle-effect.js +1 -0
  24. package/dist/persistence/drizzle.d.ts +1514 -0
  25. package/dist/persistence/drizzle.js +18 -0
  26. package/dist/resources.d.ts +423 -0
  27. package/dist/resources.js +15 -0
  28. package/dist/runtime/effect.d.ts +954 -0
  29. package/dist/runtime/effect.js +15 -0
  30. package/dist/runtime/express.d.ts +644 -0
  31. package/dist/runtime/express.js +1 -0
  32. package/dist/runtime/nestjs.d.ts +802 -0
  33. package/dist/runtime/nestjs.js +8 -0
  34. package/dist/runtime/next.d.ts +874 -0
  35. package/dist/runtime/next.js +8 -0
  36. package/dist/runtime/web.d.ts +776 -0
  37. package/dist/runtime/web.js +8 -0
  38. package/dist/tools/ai-sdk.d.ts +170 -0
  39. package/dist/tools/ai-sdk.js +8 -0
  40. package/package.json +242 -4
package/README.md CHANGED
@@ -1,3 +1,1076 @@
1
- # Temporary Holding Version
1
+ # `@collective-intelligence/external-apps-server-sdk`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Confidential server client and proxy boundary for Collective Intelligence
4
+ external applications. Import administrative APIs only from `/admin`; import
5
+ the optional Express runtime adapter from `/runtime/express`.
6
+
7
+ The `/admin` client exposes knowledge connector administration through
8
+ `knowledgeConnectors` (formerly `connectors`). It calls the backend's
9
+ `/admin/extensions/knowledge-connector-*` resources. Upgrade callers and the
10
+ backend together; the previous namespace and API routes have no compatibility
11
+ aliases. Request and response shapes are unchanged.
12
+
13
+ The customer-hosted knowledge gateway is intentionally split into isolated
14
+ subpaths:
15
+
16
+ - `/gateway` owns deterministic `defineGateway` manifests, explicit one-level
17
+ operation domains, proposal projection, and the Web `Request`/`Response`
18
+ policy kernel;
19
+ - `/auth` verifies short-lived ES256 CI caller JWTs through tenant-bound JWKS,
20
+ including audience, installation, organization, capability binding, expiry,
21
+ and atomic `jti` replay consumption; and
22
+ - `/resources` declares promise-based resource handlers over the portable
23
+ client resource contract.
24
+
25
+ ## Choose authentication by boundary
26
+
27
+ The SDK has three separate authentication boundaries:
28
+
29
+ - `createServerClient` uses confidential service credentials for requests from
30
+ the customer server to CI. Keep those credentials on the server and use the
31
+ only supported client mode, `service`. Its `tenantUrl` must be a
32
+ credential-free HTTPS origin; for local development it also accepts an
33
+ `http` origin whose host is exactly `localhost`, a `127.0.0.0/8` address, or
34
+ `[::1]`. Any other `http` host is rejected before credentials are sent.
35
+ - Browser-facing application routes use either CI federation from
36
+ `/auth/federated` or local email/password sessions from `/auth/better-auth`.
37
+ Select one mode for a deployment and authorize those sessions at the
38
+ application route boundary.
39
+ - Gateway routes accept short-lived ES256 caller tokens signed by CI. The
40
+ customer server verifies those tokens and their live connection grant through
41
+ `/auth`; browser cookies do not authorize Gateway calls.
42
+
43
+ Administrative CI APIs use a separately supplied administrator bearer token
44
+ through `/admin`. None of these credentials or grants belongs in a Gateway
45
+ manifest.
46
+
47
+ A confidential application created by package install has no `clientSecret`
48
+ until an administrator with `public_applications:manage` rotates its credential
49
+ (`POST /admin/organization/public-applications/{id}/credential/rotate`, or
50
+ **Rotate secret** in the Dashboard). CI returns the secret once; store it
51
+ server-side. See
52
+ [Provision a secret for a package-created application](../../../docs/reference/package-release-cli.md#provision-a-secret-for-a-package-created-application).
53
+
54
+ Gateway manifests contain only serializable declarations. Handlers,
55
+ credentials, signing keys, resolved identities, and customer authorization
56
+ callbacks stay in the runtime definition. Public principals are the closed
57
+ `detached_user | service_principal` union. A delegated customer identity is
58
+ retained separately as the stable external issuer/subject audit context and
59
+ never changes caller authority.
60
+
61
+ Gateway protocol version 2 exposes Knowledge resources as remote source
62
+ metadata only. A resource definition describes the external application's own
63
+ record shape and capabilities; it never declares a CI organization domain,
64
+ entity schema, relation schema, schema FQN, or field mapping. Organization
65
+ administrators select exposed resources and map them to organization-owned
66
+ schemas when configuring the corresponding CI Knowledge connector.
67
+ Push ingestion follows the same boundary: records contain source identity,
68
+ revision, timestamps, deletion state, and the remote value only. They cannot
69
+ declare organization relation edges or relation fields; CI derives those
70
+ semantics through the organization-owned connector mapping.
71
+ `createGatewayIngestionClient` from `/gateway` accepts the same `tenantUrl`
72
+ values as `createServerClient`, including a loopback `http` origin for local
73
+ development.
74
+
75
+ Each `defineOperation` must fully materialize the policy CI reviews: portable
76
+ input/output schemas, `effects`, `idempotency`, `retry`, `authorization`,
77
+ `execution`, `limits`, and `version`. `retry` is either `{ mode: "never" }` or
78
+ an `idempotent_only` policy with 2–10 total attempts. Execution is synchronous
79
+ and declares a finite timeout. `maxAttempts` counts the initial call and every
80
+ permitted retry. `limits` bounds the request body and serialized result.
81
+ `defineGateway` rejects non-integer or unsafe bounds before hashing the
82
+ manifest: timeouts are at most one hour and request and response payloads at
83
+ most 16 MiB. Background execution is rejected at registration.
84
+
85
+ The kernel validates an optional `x-idempotency-key` once and forwards the
86
+ exact value to the operation handler. A `requires_key` operation is
87
+ never called without one. Replay lookup and durable result storage belong to
88
+ the handler and the customer's persistence layer; the framework-neutral kernel
89
+ does not claim generic deduplication.
90
+
91
+ `createGatewayKernel` serves the closed versioned discovery, manifest, health,
92
+ resource and domain-qualified synchronous operation routes. It does not expose
93
+ background execution handles, polling, cancellation or event-stream routes.
94
+ The kernel applies each operation's request, timeout and result bounds; the
95
+ gateway-global body limit remains an additional ceiling. An already-aborted
96
+ request never starts an operation. A live abort signals the handler and returns
97
+ a cancellation response; completed requests detach their abort listener. A
98
+ handler must cooperate with its signal to stop its own work. Cancellation does
99
+ not undo an already-committed mutation. Runtime adapters translate framework
100
+ values to that kernel instead of reimplementing routing, authorization, or error
101
+ policy.
102
+ Provider and framework peers are optional and isolated to their declared
103
+ `/knowledge-sources/*`, `/persistence/*`, `/runtime/*`, and `/tools/*` exports; importing the
104
+ package root or the gateway/auth/resource subpaths does not load them.
105
+
106
+ The configured `tenantUrl` must be a credential-free HTTPS origin, without a
107
+ path, query, or fragment. Mount `createExternalApplicationBff` (or
108
+ `createExternalApplicationBffNextHandlers`) once for federated auth, Resource
109
+ proxy, and dedicated Capability forwarding. Pass `ciResources` to bind one
110
+ Resource proxy mount per entity contract by `schemaFqn`; the BFF authorizes
111
+ those mounts with `authorizeWithUserAuth`. Dedicated Capability forwarding sends
112
+ the signed-in user's delegated token and the caller's `x-organization-id` by
113
+ default. For an application without federated CI users, pass
114
+ `capabilities: { authentication: { mode: "service", credentials },
115
+ getAuditSubject, organizationId, tenantUrl }`: each call carries the
116
+ application's Basic credentials, the configured organization, and the
117
+ `x-external-audit-issuer` / `x-external-audit-subject` headers from
118
+ `getAuditSubject(request)`. A request without an Audit subject receives `401`
119
+ and is never forwarded. To bring anonymous visitors to a declared Agent, add
120
+ `caller: "anonymous_visitor"`: each call also carries
121
+ `x-external-caller-kind: anonymous_visitor` and never `x-external-ci-user-id`,
122
+ so the backend runs only the Agent's own bindings and never the application's
123
+ service authority. An Audit subject with `mappedUserId` is refused with `401`
124
+ for a visitor caller. Forwarding covers `context`, `knowledge`, `agents`,
125
+ `conversations` (continue and history), `skills`, `workflows`, and `runs/*`.
126
+ Reconstruct the public origin from
127
+ `x-forwarded-host` / `x-forwarded-proto`. Compose a Fixed workflow route with
128
+ `createFixedWorkflowRoute`: it rewrites that public origin, authorizes
129
+ `getEntityContext` plus `requiredScopes`, stamps `x-organization-id` from the
130
+ session, POSTs `/application/v1/workflows/:id/runs`, and returns `{ runId }`.
131
+ Configure exactly one server-owned target: `workflowId` for a workflow UUID or
132
+ `workflowFqn` for its stable fully qualified name. The route rejects either
133
+ field in browser input and encodes the configured target as one URL segment.
134
+ Override `validate`, `idempotencyKey`, `authorize`, or `getAccessToken` when
135
+ the application needs different policy. The deprecated `./express` entry
136
+ re-exports only `createGatewayExpressMiddleware` from `./runtime/express`.
137
+
138
+ ## Reuse Nest operations and local AI tools
139
+
140
+ Use Node 22+ with ESM for the AI SDK 7 adapter. The inspected and tested baseline on 6 October 2026
141
+ is `ai@7.0.128`, `@ai-sdk/provider-utils@5.0.54`, and Nest `11.2.4`, with
142
+ Zod `4.4.3` in workspace source checks and the Nest demo, and `4.6.5` in the
143
+ installed AI consumer; recheck the stable AI patch when installing. Declared optional peer
144
+ ranges are Nest `>=10 <12`, AI `>=7.0.128 <8`, provider-utils `>=5.0.54 <6`,
145
+ and Zod `>=4 <5`. A peer range is not proof of every version combination.
146
+ Install only the peers required by the subpaths you use. Nest applications also
147
+ need their HTTP platform adapter, `reflect-metadata`, and `rxjs`.
148
+
149
+ Workspace SDK, backend, and Nest demo dependencies align on `zod@4.4.3`. Bun
150
+ resolves separate Drizzle and AI provider-utils installations for different peer
151
+ versions; aligning Zod preserves their shared database and schema type identities.
152
+ The installed AI consumer continues to verify `zod@4.6.5` in one peer context.
153
+
154
+ - `/runtime/nestjs` exports `bindNestOperation`, `GatewayNestModule`, and
155
+ `createGatewayNestHandler`. It composes supplied application providers over
156
+ the existing Fetch kernel.
157
+ - `/tools/ai-sdk` exports asynchronous `defineAiSdkOperation`. It imports a
158
+ supplied local tool; it does not generate tools or execute model generation.
159
+
160
+ The package root and `/gateway` do not load these optional peers. Use public
161
+ subpaths rather than private implementation imports. For runnable Nest DI,
162
+ caller verification, manifests, and CI Connection/binding setup, follow the
163
+ [Nest demo](../../../demo/external-app-nest/README.md).
164
+
165
+ ### Bind an existing Nest callable
166
+
167
+ `bindNestOperation({ declaration, normalize, authorize, execute })` returns a
168
+ Gateway Operation. All three callbacks are required. `declaration` contains the
169
+ same metadata and portable input/output projections as `defineOperation`;
170
+ `normalize` uses your native schema, `authorize` receives the normalized input
171
+ and verified invocation context, and `execute` calls your existing injected
172
+ service. The invocation also carries `signal` and an optional validated
173
+ `idempotencyKey`. Bind instance methods through a closure to retain `this`.
174
+
175
+ ```ts
176
+ import { bindNestOperation } from "@collective-intelligence/external-apps-server-sdk/runtime/nestjs"
177
+
178
+ const operation = bindNestOperation({
179
+ declaration: greetingDeclaration,
180
+ normalize: ({ input }) => greetingInput.parse(input),
181
+ authorize: ({ input, context }) => permissions.canGreet(context, input),
182
+ execute: ({ input, signal }) => greetings.greet(input, { signal })
183
+ })
184
+ ```
185
+
186
+ `greetingDeclaration`, `greetingInput`, `permissions`, and `greetings` are
187
+ application-owned dependencies; the demo supplies a concrete composition.
188
+ The binding does not inherit controller guards, middleware, pipes, interceptors,
189
+ or serializers. Reuse business policy explicitly. Connection admission is
190
+ followed by application/resource authorization; an External Audit Subject does
191
+ not supply an application user or access rights.
192
+
193
+ ### Reuse an existing AI SDK tool
194
+
195
+ This complete tool binding demonstrates native defaults/transforms, a validated
196
+ context factory, an explicit missing-output override, and business admission.
197
+ The supplied permission service is the application integration point.
198
+
199
+ ```ts
200
+ import { defineAiSdkOperation } from "@collective-intelligence/external-apps-server-sdk/tools/ai-sdk"
201
+ import type { GatewayInvocationContext } from "@collective-intelligence/external-apps-server-sdk/auth"
202
+ import { tool } from "ai"
203
+ import { z } from "zod"
204
+
205
+ export async function bindGreetingTool(permissions: {
206
+ canGreet(context: GatewayInvocationContext, name: string): Promise<boolean>
207
+ }) {
208
+ const existing = tool({
209
+ description: "Greet an authorized name",
210
+ inputSchema: z.object({
211
+ name: z.string().transform((value) => value.trim()),
212
+ prefix: z.string().default("Hello")
213
+ }),
214
+ contextSchema: z.object({ organizationId: z.string().min(1) }),
215
+ execute: async ({ name, prefix }, options) => ({
216
+ greeting: `${prefix} ${name}`,
217
+ organizationId: options.context.organizationId
218
+ })
219
+ })
220
+ return await defineAiSdkOperation({
221
+ id: "greet",
222
+ title: "Greet",
223
+ version: "1.0.0",
224
+ tool: existing,
225
+ effects: "read",
226
+ idempotency: "safe",
227
+ retry: { mode: "never" },
228
+ authorization: {
229
+ organization: "required",
230
+ delegatedSubject: "allowed",
231
+ principalKinds: ["service_principal"],
232
+ scopes: ["greetings:read"]
233
+ },
234
+ execution: { mode: "synchronous", timeoutMs: 1000 },
235
+ limits: { maxRequestBytes: 1024, maxResponseBytes: 1024 },
236
+ authorize: ({ input, context }) =>
237
+ permissions.canGreet(context, input.name),
238
+ context: ({ context }) => ({ organizationId: context.organizationId }),
239
+ output: {
240
+ dialect: "https://json-schema.org/draft/2020-12/schema",
241
+ fidelity: "exact",
242
+ document: {
243
+ type: "object",
244
+ additionalProperties: false,
245
+ properties: {
246
+ greeting: { type: "string" },
247
+ organizationId: { type: "string" }
248
+ },
249
+ required: ["greeting", "organizationId"]
250
+ }
251
+ }
252
+ })
253
+ }
254
+ ```
255
+
256
+ Await the operation before putting it in `defineOperationDomain` and
257
+ `defineGateway`, then mount `createGatewayKernel({ gateway, verifyCaller })`
258
+ through `GatewayNestModule.registerAsync({ imports, inject, useFactory })`.
259
+ Keep handlers, runtime context, secrets, and DI instances out of the manifest.
260
+ Configure the trusted CI issuer, audience, atomic replay store, live Connection
261
+ grant check, and accepted operation binding before invoking it. The demo
262
+ explains those prerequisites and the existing administrative setup seams.
263
+
264
+ Admission requires a local `execute` callback and one bounded JSON result.
265
+ Provider/client-only tools, streaming/preliminary results, generation lifecycle
266
+ callbacks (`onInputStart`, `onInputDelta`, `onInputAvailable`), approval needs,
267
+ and `toModelOutput` are unsupported. Registration rejects unsupported tool
268
+ features; a streamed/non-JSON execution result produces a bounded capability
269
+ diagnostic. A context-dependent description needs a fixed `description`
270
+ override. Missing input/output projections prevent publication.
271
+
272
+ The adapter projects supported Zod 4, Standard JSON Schema, and AI JSON Schema
273
+ inputs/outputs into Draft2020-12. Native input parsing still supplies defaults
274
+ and transforms before authorization and execution. `input` and `output`
275
+ overrides are portable `PackageSchemaProjection` values for schema gaps;
276
+ input must satisfy `ai_function_input`, output must satisfy `runtime_json`.
277
+ Nested `true`/`false` subschemas are preserved under supported keywords;
278
+ projection roots remain objects, and AI-input profile restrictions and native
279
+ validation still apply.
280
+ Do not overstate refinements or transforms that JSON Schema cannot encode.
281
+ Overrides retain native input validation. Output is checked against its
282
+ published projection and byte limit, not passed through a native output
283
+ transform. Unknown/unregistered format constraints and unsupported conversions
284
+ require a supported explicit projection; do not silence them with an empty
285
+ schema. See [portable schemas](../extensions-sdk/README.md).
286
+
287
+ A tool with context needs an application-owned `context(request)` factory and
288
+ native validator, from `tool.contextSchema` or the adapter's `contextSchema`
289
+ option. Construct context from the verified caller and authorized resources.
290
+ The factory result is validated before execution; invalid context never
291
+ executes the tool. The adapter supplies `toolCallId` from CI's correlation ID,
292
+ `abortSignal` from the invocation, and `messages: []`. It cannot reconstruct
293
+ original model messages or approval authority. Cancellation is cooperative;
294
+ ignored signals can leave work running and cannot roll back writes. Arbitrary
295
+ exceptions remain private; use `GatewayOperationError` from `/gateway` only
296
+ for deliberately safe caller-facing failures.
297
+
298
+ For multi-step CV work, use application-owned start/status bindings rather
299
+ than waiting for a workflow in this bounded tool adapter. The
300
+ [CV integration guide](../../../demo/external-app-nest/docs/cv-integration.md)
301
+ shows distinct receipts and final results, independent resource authorization,
302
+ and application-owned execution, recovery, failure, and duplicate protection.
303
+
304
+ ## Agent runs and conversations
305
+
306
+ `agents.invoke({ agentId, input })` starts one Agent run and returns
307
+ `{ runId }`. Read it with `runs.get` (a `ServerRun`; a succeeded Agent run's
308
+ `result` is a `ServerAgentRunResult`, `{ message, output? }`) or page its events
309
+ with `runs.stream`. `message` is the Agent's normal final answer. `output` is the
310
+ structured Agent Output the run saved, already valid against the Agent's
311
+ declared output schema; it is absent when the run saved none, such as a
312
+ clarifying question. Reading a run again returns the same saved result.
313
+
314
+ For a multi-turn chat, use `conversations` instead. The first message starts a
315
+ conversation; each later message continues it:
316
+
317
+ ```ts
318
+ const ci = runAsApplication({
319
+ auditSubject: { issuer: "apito.it", subjectId: memberId },
320
+ credentials,
321
+ tenantUrl
322
+ })
323
+
324
+ const first = await ci.conversations.continue({
325
+ agentId,
326
+ input: "Find suppliers of recycled packaging",
327
+ organizationId
328
+ })
329
+ // first.value.result: { conversationId, runId }
330
+
331
+ const next = await ci.conversations.continue({
332
+ conversationId,
333
+ input: "Tell me more about the second one",
334
+ organizationId
335
+ })
336
+
337
+ const history = await ci.conversations.history({
338
+ conversationId,
339
+ organizationId
340
+ })
341
+ // history.value.result: { agentId, conversationId, createdAt, turns }
342
+ ```
343
+
344
+ Each turn is an ordinary Agent run: follow it with `runs.get` / `runs.stream`
345
+ using its `runId`. The run of a continued turn receives the earlier turns as
346
+ the model's own conversation history: each earlier message and answer, plus
347
+ any Agent Output that turn saved, before the new message. Only turns that
348
+ succeeded before the new turn started count. A turn is
349
+ `{ runId, status, input, message, output?, createdAt, completedAt }`:
350
+ `message` is the Agent's final answer once the run succeeded, otherwise
351
+ `null`; `output` is present when the run saved an Agent Output.
352
+
353
+ - A conversation belongs to the application, organization and Audit subject
354
+ that started it. Another subject (or a call without that subject) gets
355
+ `not_found` for both `continue` and `history`.
356
+ - Wait until the previous turn finished: continuing while it still runs returns
357
+ `conflict`.
358
+ - `idempotencyKey` (at most 128 characters; generated when omitted) makes a
359
+ retried message replay the same turn instead of adding one. Use each key for
360
+ one message only; reusing it for a different message or conversation returns
361
+ `conflict`.
362
+ - The application's Agent capability gates conversations, with the `ai:invoke`
363
+ scope.
364
+ - The Agent's current audience gates every turn and every `history` read. When
365
+ an administrator removes the caller from the audience, the same caller gets
366
+ `not_found` for its own conversations too.
367
+ - For an anonymous visitor, pass `callerKind: "anonymous_visitor"` with the
368
+ visitor's `auditSubject` (no `mappedUserId`). Only a declared Agent with
369
+ `audience: { anonymous: true }` that lists this application admits it. The
370
+ visitor's turns run with the Agent's own bindings under an anonymous visitor
371
+ subject, never with the application's scopes. Visitors are separate owners:
372
+ one visitor never reads another visitor's conversation.
373
+
374
+ ## Gateway registry and orchestration
375
+
376
+ Build one registry with `defineGateway`. Its `knowledge`, `datasets`, and
377
+ `operationDomains` arrays are the complete allowlist projected into discovery.
378
+ `defineGateway` snapshots those declarations, derives the deterministic
379
+ manifest and resolves the executable binding for each incoming route.
380
+
381
+ Pass that registry and one machine caller verifier to `createGatewayKernel`.
382
+ The kernel owns discovery, connection enrollment, authentication,
383
+ authorization, bounded JSON handling and dispatch. A runtime adapter then lifts
384
+ the same Fetch `Request`/`Response` seam into the host framework. Application
385
+ routes can reuse the same CRUD resource with their own user-session policy.
386
+
387
+ ```ts
388
+ const gateway = defineGateway({
389
+ id: "crm",
390
+ version: "1.0.0",
391
+ knowledge: [{ resource: leadResource, access: "read_write" }],
392
+ datasets: [leadPipeline],
393
+ operationDomains: [crmOperations]
394
+ })
395
+
396
+ const kernel = createGatewayKernel({
397
+ gateway,
398
+ verifyCaller: connectionAuth.verifyCaller,
399
+ verifySetupCredential: connectionAuth.verifySetupCredential,
400
+ enrollConnection: connectionAuth.enrollConnection
401
+ })
402
+ ```
403
+
404
+ This package exposes Knowledge, Dataset, and declared operation tools through
405
+ the Gateway protocol. An MCP server or MCP transport is outside this SDK.
406
+
407
+ ### Enroll and verify connections
408
+
409
+ `createGatewayConnectionAuthentication` composes a durable connection-grant
410
+ store, a replay store, trusted CI issuers, and a server-only setup credential.
411
+ Wire its three returned functions into the kernel as shown above.
412
+
413
+ ```ts
414
+ const connectionAuth = createGatewayConnectionAuthentication({
415
+ audience: "https://crm.example/gateway",
416
+ trustedIssuers: [
417
+ { issuer: "https://ci.example", tenantUrl: "https://ci.example" }
418
+ ],
419
+ grants: connectionGrantStore,
420
+ replayStore,
421
+ setupCredentials: loadActiveSetupCredentials
422
+ })
423
+ ```
424
+
425
+ CI starts enrollment with a signed discovery token and the configured setup
426
+ credential. The verifier validates the token signature, issuer, audience,
427
+ connection, organization, capability binding, scopes, expiry and unique `jti`,
428
+ then confirms the connection with CI before persisting the grant. Normal calls
429
+ must match an enrolled grant and repeat that live validation. Store grants and
430
+ replay identifiers durably and atomically when the application runs more than
431
+ one process. `createInMemoryReplayStore` is suitable only for a single-process
432
+ fixture. The SDK verifies CI signatures; it does not issue caller tokens.
433
+
434
+ ### Package-managed enrollment
435
+
436
+ For a declared Gateway, configure `/auth`'s `createGatewayPackageEnrollment`
437
+ with the exact audience, explicitly trusted CI issuers, a local `allowedPackages`
438
+ list of `{ organizationId, packageFqn, gatewayFqn }` tuples, and an atomic
439
+ `GatewayPackageEnrollmentStore`. Pass its `enrollPackageConnection` callback to
440
+ `createGatewayKernel` alongside the normal invocation verifier. The kernel
441
+ supplies its runtime manifest hash to the callback; do not substitute a hash
442
+ from request input.
443
+
444
+ `POST /v1/connections/package-enroll` accepts a narrow signed Bearer token and
445
+ returns `{ enrolled: true, manifest }`. This route requires no setup credential.
446
+ The token has purpose `package_gateway_enrollment`, binding `connection:enroll`,
447
+ scope `gateway:enroll`, an exact audience, an issued-at time and a lifetime of at
448
+ most 300 seconds. Its `org_id`, `package_installation_id`, `package_fqn`,
449
+ `gateway_fqn`, `installation_id` (the Connection ID), `manifest_hash` and `jti`
450
+ are required. Configured keys, local package policy and the complete runtime
451
+ manifest hash must agree. The verifier also checks live CI enrollment authority
452
+ before storage, with bounded requests and no redirects. Enrollment tokens never
453
+ authorize operation invocation.
454
+
455
+ `makeGatewayPackageEnrollmentStore` from `/persistence/drizzle` commits nonce
456
+ consumption and the complete grant in one PostgreSQL transaction. It is also
457
+ available as `makePlatformPersistence(...).gateway.enrollment`. App-owned
458
+ migrations must add nullable text columns `package_installation_id`,
459
+ `package_fqn`, `gateway_fqn`, `audience`, and `manifest_hash` to
460
+ `gateway_connections` before using this managed route. Legacy grant
461
+ `has`/`put`/`remove` works with the original three-column table. The SDK executes
462
+ no implicit migration.
463
+
464
+ The [Nest server integration](../../../demo/external-app-nest/apps/server/src/integrations/collective-intelligence/gateway-security.ts) supplies configured trust and allowed identities; [exact deployment](../../../docs/reference/package-release-cli.md#deploy-an-exact-package-and-its-declared-gateways) uses this route without broad credentials reaching the server.
465
+
466
+ Concurrent delivery of one token admits one request; a later grant write failure
467
+ rolls back nonce consumption. Retry a lost acknowledgement with a fresh signed
468
+ token for the same owning installation and Connection. A different package
469
+ owner cannot replace that grant. Existing setup enrollment remains available
470
+ through `enrollConnection`; normal calls still require an enrolled grant, live
471
+ CI authority, operation scopes, replay protection and application authorization.
472
+
473
+ ## Bounded custom operations
474
+
475
+ Define each operation once and include it in a named operation domain. CI admits
476
+ only explicitly selected operations from the accepted connection revision. Its
477
+ generic tool input uses the existing `ai-function-input` schema profile: an
478
+ object root with supported keywords. Incompatible inputs are rejected at
479
+ admission; their schemas are not rewritten. Declared JSON output may be an
480
+ object, array or scalar and is validated before returning a successful result.
481
+
482
+ ```ts
483
+ import {
484
+ defineOperation,
485
+ defineOperationDomain
486
+ } from "@collective-intelligence/external-apps-server-sdk/gateway"
487
+
488
+ const quote = defineOperation<{ quantity: number }, { total: number }>({
489
+ id: "quote",
490
+ version: "1.0.0",
491
+ title: "Quote",
492
+ description: "Calculate a quote for an opportunity.",
493
+ input: {
494
+ dialect: "https://json-schema.org/draft/2020-12/schema",
495
+ fidelity: "exact",
496
+ document: {
497
+ type: "object",
498
+ properties: { quantity: { type: "number", minimum: 1 } },
499
+ required: ["quantity"],
500
+ additionalProperties: false
501
+ }
502
+ },
503
+ output: {
504
+ dialect: "https://json-schema.org/draft/2020-12/schema",
505
+ fidelity: "exact",
506
+ document: {
507
+ type: "object",
508
+ properties: { total: { type: "number" } },
509
+ required: ["total"],
510
+ additionalProperties: false
511
+ }
512
+ },
513
+ effects: "read",
514
+ idempotency: "safe",
515
+ retry: { mode: "never" },
516
+ authorization: {
517
+ organization: "required",
518
+ principalKinds: ["service_principal"],
519
+ delegatedSubject: "forbidden",
520
+ scopes: ["quotes:read"]
521
+ },
522
+ execution: { mode: "synchronous", timeoutMs: 5000 },
523
+ limits: { maxRequestBytes: 4096, maxResponseBytes: 4096 },
524
+ handler: async ({ input }) => ({ total: input.quantity * 42 })
525
+ })
526
+
527
+ const crm = defineOperationDomain({
528
+ id: "crm",
529
+ title: "CRM",
530
+ operations: [quote]
531
+ })
532
+ // Include crm in defineGateway({ ...definition, operationDomains: [crm] }).
533
+ ```
534
+
535
+ Mutations that require replay protection declare `idempotency: "requires_key"`.
536
+ The host must atomically associate the key with its mutation and original result
537
+ in its own database. CI persists its invocation receipt and only permits retries
538
+ under the declared policy; that receipt cannot replace application-side replay
539
+ protection after an uncertain network outcome. The declared timeout bounds CI's
540
+ whole invocation attempt sequence, including caller authentication and retries.
541
+
542
+ ## Identifiers
543
+
544
+ Import `uuidV7` from the package root when an application needs a portable,
545
+ time-ordered UUID default. It uses the host's Web Crypto implementation and
546
+ does not load any optional framework peer.
547
+
548
+ ## External application modules
549
+
550
+ `ExternalAppSubmodule<T>` validates an application-owned module with a required
551
+ `gateway` declaration. That nested declaration must provide `knowledge`, a
552
+ non-empty `datasets` list, or a non-empty `operations` list; application-owned
553
+ fields such as `resource` and `conversion` remain alongside it. Pass the
554
+ factory's inferred return type so those additional fields remain strongly typed.
555
+ `ExternalAppModule<Submodules>` validates the named submodule map without
556
+ widening those submodules:
557
+
558
+ ```ts
559
+ import type {
560
+ ExternalAppModule,
561
+ ExternalAppSubmodule
562
+ } from "@collective-intelligence/external-apps-server-sdk/gateway"
563
+
564
+ type LeadsSubmodule = ExternalAppSubmodule<
565
+ ReturnType<typeof makeCrmLeadsSubmodule>
566
+ >
567
+ type OpportunitiesSubmodule = ExternalAppSubmodule<
568
+ ReturnType<typeof makeCrmOpportunitiesSubmodule>
569
+ >
570
+
571
+ type CrmModule = ExternalAppModule<{
572
+ leads: LeadsSubmodule
573
+ opportunities: OpportunitiesSubmodule
574
+ }>
575
+ ```
576
+
577
+ ## Effect authoring
578
+
579
+ Install the optional `effect` peer and import helpers from `/runtime/effect`.
580
+ The executable consumer is tested with `effect@4.0.1`. Core SDK imports
581
+ do not load Effect. `DateTimeString` and `UuidV7` provide reusable wire schemas;
582
+ `jsonSchema` projects an Effect schema into the JSON Schema document accepted
583
+ by gateway resource and operation definitions. The projection rejects unknown
584
+ properties for `Schema.Struct` objects while preserving the value
585
+ constraints of open `Schema.Record` objects.
586
+
587
+ `makeEffectCrudResource` acquires the supplied repository Effect once when the
588
+ composition program runs. It returns the same portable `CrudResource` used by
589
+ application HTTP and gateway knowledge bindings. Its acquisition error and
590
+ service requirements remain in the Effect type; provide the application's
591
+ Layer and keep its scope open for the lifetime of the handlers.
592
+
593
+ ```ts
594
+ import * as Effect from "effect/Effect"
595
+ import * as Schema from "effect/Schema"
596
+ import {
597
+ createResourceEffectHandler,
598
+ DateTimeString,
599
+ jsonSchema,
600
+ makeEffectCrudResource,
601
+ UuidV7
602
+ } from "@collective-intelligence/external-apps-server-sdk/runtime/effect"
603
+
604
+ const leadSchema = Schema.Struct({
605
+ createdAt: DateTimeString,
606
+ id: UuidV7,
607
+ name: Schema.NonEmptyString
608
+ })
609
+ const leadJsonSchema = jsonSchema(leadSchema)
610
+
611
+ const application = Effect.gen(function* application() {
612
+ const resource = yield* makeEffectCrudResource({
613
+ definition: leadDefinition,
614
+ authorization: leadAuthorization,
615
+ repository: leadRepositoryEffect
616
+ })
617
+ const handle = createResourceEffectHandler({
618
+ resource,
619
+ basePath: "/api/leads",
620
+ authorize: authorizeApplicationRequest
621
+ })
622
+ return { resource, handle }
623
+ })
624
+ ```
625
+
626
+ Here `leadRepositoryEffect` acquires the host's standard repository from its
627
+ Layer; `authorizeApplicationRequest` returns an Effect containing `true` or an
628
+ explicit denial `Response`. Typed policy failures and service requirements
629
+ remain visible to the host. Bind the returned `resource` to `defineGateway` and
630
+ lift its Fetch kernel with `createGatewayEffectHandler`. These adapters use the
631
+ same validation, CRUD behavior, SQL and authorization boundaries as the native
632
+ Fetch handlers.
633
+
634
+ Already-aborted requests never start host work. Live request aborts interrupt
635
+ the Effect fiber, and fiber interruption aborts the forwarded Request signal.
636
+ Interruption waits for finalizers within the authorization Effect (including
637
+ its explicitly scoped resources) before the handler exits. A standard database
638
+ operation already submitted to PostgreSQL may still commit; cancellation does
639
+ not promise SQL rollback. The host owns database and server shutdown through
640
+ its Layer/scope.
641
+
642
+ `test/consumer/effect/run.ts` is an executable example using a Layer-owned
643
+ PostgreSQL fixture, one Drizzle repository, and actual application/gateway HTTP
644
+ requests. `bun run test:pack` validates it from the published tarball and removes
645
+ its owned database and server afterward.
646
+
647
+ ## NestJS authoring
648
+
649
+ Import `GatewayNestModule` and `CrudResourceNestModule` from `/runtime/nestjs`.
650
+ They create native Nest dynamic modules, controllers and factory providers.
651
+ Use ordinary Nest `imports`, `inject`, and `useFactory` options; asynchronous
652
+ providers are supported. The host's module owns and exports one CRUD resource,
653
+ and both registrations inject that same resource. No global registry is used.
654
+
655
+ ```ts
656
+ import type { CrudResource } from "@collective-intelligence/external-apps-server-sdk/resources"
657
+ import {
658
+ CrudResourceNestModule,
659
+ GatewayNestModule
660
+ } from "@collective-intelligence/external-apps-server-sdk/runtime/nestjs"
661
+
662
+ const applicationRoutes = CrudResourceNestModule.registerAsync({
663
+ imports: [LeadsModule],
664
+ inject: [LEAD_RESOURCE],
665
+ path: "/api/leads",
666
+ useFactory: (resource: CrudResource) => ({
667
+ resource,
668
+ authorize: authorizeApplicationRequest
669
+ })
670
+ })
671
+ const gatewayRoutes = GatewayNestModule.registerAsync({
672
+ imports: [LeadsModule],
673
+ inject: [LEAD_RESOURCE],
674
+ useFactory: (resource: CrudResource) =>
675
+ createGatewayKernel({
676
+ gateway: defineGateway({
677
+ ...gatewayDefinition,
678
+ knowledge: [{ resource, access: "read_write" }]
679
+ }),
680
+ verifyCaller: verifyMachineCaller
681
+ })
682
+ })
683
+ ```
684
+
685
+ Add both returned modules to the application's `@Module({ imports: [...] })`.
686
+ `LeadsModule` owns the repository, SQL driver, migrations, and `LEAD_RESOURCE`
687
+ provider. `authorizeApplicationRequest` returns `true` or a denial `Response`;
688
+ `verifyMachineCaller` supplies the separately configured gateway authority.
689
+ Set a knowledge binding to `access: "read"` to limit its gateway operations
690
+ without removing the application's own authorized CRUD capabilities.
691
+
692
+ The gateway uses its canonical `/v1/*` and discovery routes at the application
693
+ origin; do not add a Nest global prefix to those routes. Start Nest with
694
+ `NestFactory.create(AppModule, { bodyParser: false })` so the SDK owns bounded
695
+ JSON parsing. Other application routes can install their own body parser.
696
+ The executable consumer uses Nest's Express platform; its native wildcard
697
+ syntax also remains compatible with Nest 10 routing.
698
+
699
+ `test/consumer/nestjs/application.ts` contains the complete typed application
700
+ composition. `bun test src/adapters/runtime/nestjs.test.ts` runs it against isolated
701
+ PostgreSQL and real HTTP. `bun run test:pack` compiles and runs the installed
702
+ Nest consumer and verifies package-root imports without Nest peers. The host
703
+ owns application shutdown and database cleanup.
704
+
705
+ ## Next.js, Web and Express hosting
706
+
707
+ `createResourceNextRouteHandlers` from `/runtime/next` wraps the same resource
708
+ HTTP handler used by other hosts. In an App Router route such as
709
+ `app/api/leads/[...path]/route.ts`, export its method handlers:
710
+
711
+ ```ts
712
+ import { createResourceNextRouteHandlers } from "@collective-intelligence/external-apps-server-sdk/runtime/next"
713
+ import { leadResource, authorizeApplicationRequest } from "../../../../server"
714
+
715
+ export const runtime = "nodejs"
716
+ export const dynamic = "force-dynamic"
717
+ export const { GET, POST, PATCH, DELETE } = createResourceNextRouteHandlers({
718
+ resource: leadResource,
719
+ basePath: "/api/leads",
720
+ authorize: authorizeApplicationRequest
721
+ })
722
+ ```
723
+
724
+ Bind that same resource to the gateway and export
725
+ `createGatewayNextRouteHandlers(kernel)` from `app/v1/[...path]/route.ts`.
726
+ The application policy authenticates browser requests; gateway routes retain
727
+ machine authentication and their declared read or write exposure. A writable
728
+ application resource can have a read-only gateway binding.
729
+
730
+ For Fetch hosts, `createGatewayWebHandler(kernel)` returns the canonical Gateway
731
+ `(Request) => Promise<Response>` handler. A resource HTTP handler already uses
732
+ that same Fetch shape. Express adapts either one:
733
+
734
+ ```ts
735
+ app.use(
736
+ "/api",
737
+ createGatewayExpressMiddleware({
738
+ handle: createResourceHttpHandler({
739
+ resource: leadResource,
740
+ basePath: "/leads",
741
+ authorize: authorizeApplicationRequest
742
+ })
743
+ })
744
+ )
745
+ app.use("/ci", createGatewayExpressMiddleware(kernel))
746
+ ```
747
+
748
+ Express removes its mount prefix, so these routes are `/api/leads/search` and
749
+ `/ci/v1/entities/leads/search`. Mount the middleware before a body parser to let
750
+ the shared handler enforce its byte limit while reading the request stream.
751
+ Oversized uploads receive a bounded error without waiting for EOF; the adapter
752
+ flushes that response before closing the unread connection. Client disconnects
753
+ abort the forwarded request signal. The host owns server and database shutdown.
754
+
755
+ `test/consumer/next` builds and starts a real Next application, then exercises
756
+ Next, Express and Web against one PostgreSQL resource. `bun run test:pack`
757
+ compiles and runs that consumer from the installed package.
758
+
759
+ ## Shared CRUD resources
760
+
761
+ Expose a remote resource once with `defineGateway({ knowledge: [{ resource,
762
+ access: "read_write" }], ... })`. The exposure supplies discovery and dispatch;
763
+ `createGatewayKernel` no longer accepts a separate resource list. Use `access:
764
+ "read"` to expose only search/get. Every exposed operation requires an explicit
765
+ authorization declaration. Registrations are copied and frozen at construction.
766
+ All connections see the same source records. The `installed_organization` marker
767
+ identifies CI's destination organization; it never adds a source-row predicate.
768
+
769
+ `defineCrudResource` accepts a portable resource definition, a `CrudRepository`,
770
+ and gateway authorization policies. Its validated `executeCrud` service is also
771
+ used by `createResourceHttpHandler({ resource, basePath, authorize })`; the host
772
+ must authorize browser requests explicitly. A browser session is not a machine
773
+ caller identity. Both transports enforce declared capabilities, schemas,
774
+ revision preconditions and idempotency keys.
775
+
776
+ The application routes are `POST /search`, `POST /records`, and
777
+ `GET|PATCH|DELETE /records/:id`, relative to `basePath`. Search accepts nested
778
+ `and`, `or`, and `not` filters, declared field comparisons, membership, null and
779
+ text predicates, sorting, text search, and page/pageSize pagination. Page size
780
+ is bounded to 100. The PostgreSQL repository adds identity fields as stable
781
+ sorting tie-breakers and reads totals in the same database snapshot.
782
+
783
+ Import Drizzle persistence from
784
+ `@collective-intelligence/external-apps-server-sdk/persistence/drizzle`.
785
+ `definePlatformTables()` declares the SDK-owned federated-session, Gateway
786
+ Connection, replay, CRUD-journal, and CRUD-idempotency tables in the PostgreSQL
787
+ `platform` schema. Export those models from the application's Drizzle schema so
788
+ the host's normal migration workflow discovers them. The SDK never creates
789
+ tables at runtime.
790
+
791
+ ```ts
792
+ export const platformTables = definePlatformTables()
793
+ const platformPersistence = makePlatformPersistence({
794
+ database,
795
+ tables: platformTables
796
+ })
797
+
798
+ const repository = new DrizzlePostgresRepository({
799
+ database,
800
+ table: leads,
801
+ definition: leadDefinition,
802
+ revisionField: "revision",
803
+ persistence: platformPersistence.resources
804
+ })
805
+ const resource = defineCrudResource({
806
+ definition: leadDefinition,
807
+ repository,
808
+ authorization: leadAuthorization
809
+ })
810
+ ```
811
+
812
+ `makePlatformPersistence()` wires all SDK-owned tables to the federated-session,
813
+ Gateway grant, replay, and CRUD persistence contracts in one call. Use that
814
+ bundle at the application composition root so generic plumbing stays in the SDK
815
+ while authentication policy and configuration remain app-owned. The same
816
+ entrypoint also exports the individual store factories for hosts that need
817
+ custom composition.
818
+
819
+ Effect applications may import `DrizzlePostgres`, `layerFromDatabase`, and
820
+ `layerNodePostgres` from
821
+ `@collective-intelligence/external-apps-server-sdk/persistence/drizzle/effect`.
822
+ The Node PostgreSQL layer owns the pool lifetime and accepts an explicit
823
+ `migrate` hook. It never discovers a migration directory or runs hidden DDL.
824
+
825
+ The generic machine resource API retains its explicit hard-delete behavior.
826
+ Updates and hard deletes compare and replace the revision atomically. Stale
827
+ writes return a nonretryable conflict (HTTP 409). Identity and revision fields
828
+ cannot be supplied in update values. Creates persist the idempotency key,
829
+ input fingerprint and original result in the same transaction as the entity
830
+ and change journal. Reusing a key with different input conflicts. Committed
831
+ upserts and hard-delete tombstones are available through `/changes` with a
832
+ source-bound watermark. Generic cursor pagination and restore are not implemented
833
+ by this PostgreSQL adapter and cannot be advertised. Browser entity CRUD uses
834
+ the separate policy-aware port below.
835
+
836
+ When `entities.deletedAtField` is configured, generic `get`, `list`, `scan`,
837
+ `update` and `hardDelete` also exclude soft-deleted rows. Repositories without
838
+ `entities` retain their existing generic behavior.
839
+
840
+ Repository results must contain JSON data. For native driver values, provide a
841
+ typed `project` callback, for example `project: row => ({ ...row, createdAt:
842
+ row.createdAt.toISOString() })`. Unmapped native values are rejected, ensuring
843
+ the initial result and persisted idempotent retry have identical representations.
844
+
845
+ Run `bun run test:postgres` for the isolated real PostgreSQL contract suite;
846
+ Docker must be available. The runner owns a unique container and verifies its
847
+ removal. `bun run test:pack` also compiles and runs installed Drizzle and Effect
848
+ consumers through the published persistence entrypoints.
849
+
850
+ ### Authorized browser entity CRUD
851
+
852
+ `defineEntityResource({ definition, repository })` binds the common page-based
853
+ entity protocol to `EntityRepository`. Configure `entities` on the existing
854
+ `DrizzlePostgresRepository` and pass `repository.entities` to the binding:
855
+
856
+ ```ts
857
+ const repository = new DrizzlePostgresRepository({
858
+ database,
859
+ table: leads,
860
+ definition: leadDefinition,
861
+ revisionField: "revision",
862
+ persistence: platformPersistence.resources,
863
+ entities: {
864
+ storeId: "customer-crm",
865
+ deletedAtField: "deletedAt",
866
+ policy: {
867
+ authorizeOperation: authorizeLeadOperation,
868
+ rowPredicate: authorizedLeadRows,
869
+ authorizeResult: authorizeLeadDisclosure
870
+ }
871
+ }
872
+ })
873
+ const resource = defineEntityResource({
874
+ definition: leadDefinition,
875
+ repository: repository.entities
876
+ })
877
+ const handle = createResourceHttpHandler({
878
+ basePath: "/api/entities/leads",
879
+ resource,
880
+ authorize: async (request) =>
881
+ (await auth.getEntityContext(request, "leads")) ??
882
+ new Response(null, { status: 401 })
883
+ })
884
+ ```
885
+
886
+ `auth` is the existing `createFederatedAuth(...)` instance. Its server-only
887
+ `getEntityContext` resolves current CI application, user, organization, scopes,
888
+ roles and team IDs; browser session JSON remains identity and expiry only.
889
+ Missing CI policy context denies entity access. Local Better Auth sessions do
890
+ not establish CI entity authority. Browser framework adapters require this
891
+ verified context instead of boolean authorization. Machine gateway calls retain
892
+ their separate caller policy.
893
+
894
+ All three local policy callbacks are required. `rowPredicate` applies before
895
+ selection, counting and writes; explicitly return `undefined` for shared local
896
+ rows. No CI organization partition is inferred. `authorizeResult` checks current
897
+ disclosure policy for fresh results and historical receipt snapshots, including
898
+ deleted records. `storeId` is a stable canonical store identity, not a request
899
+ URL. Migrate the nullable text or timestamp-with-timezone deletion column through
900
+ the application's normal schema workflow.
901
+
902
+ Common create, update and delete require an operation key. The common definition
903
+ advertises `delete: true`; deletion uses `POST /records/:id/soft-delete`.
904
+ Existing HTTP `DELETE` retains its generic hard-delete meaning and is unavailable
905
+ through the common entity binding. Missing or deleted targets return `not_found`;
906
+ a stale live revision returns `conflict`.
907
+
908
+ The canonical write, new revision, existing change journal and original-result
909
+ receipt commit atomically. Matching replay returns the original result even
910
+ after later edits or deletion, subject to current authorization. A changed intent
911
+ conflicts. The existing receipt table and retention remain in use, without a
912
+ second store or automatic expiry. Synchronization and derived delivery consume
913
+ the committed journal separately; later failure cannot undo the save or cause
914
+ replay to write again.
915
+
916
+ Every application-owned writer, including custom operations and imports, must
917
+ participate in the same revision and transactional journal authority. Raw writes
918
+ that bypass it invalidate stale-write protection. Local relationship constraints
919
+ remain application-owned; soft deletion does not cascade to adjacent records.
920
+ Patches replace supplied top-level fields, validate the full candidate, reject
921
+ empty patches and preserve omitted fields. Nonempty equal-value updates still
922
+ advance the revision.
923
+
924
+ Queries use coherent page/count snapshots, application visibility before
925
+ pagination, literal text search, Unicode code-point string ordering, nulls last
926
+ in both directions and identity tie-breakers. Normal reads exclude tombstones.
927
+ The isolated PostgreSQL suite proves these behaviors; the installed package
928
+ consumer also checks common create/update/delete, original-result replay and
929
+ denied replay through public entrypoints.
930
+
931
+ ## Dataset queries
932
+
933
+ Register a `GatewayDatasetBinding` beside Knowledge resources and operation
934
+ domains. The binding declares one Dataset definition, authorization policy and
935
+ query callback. Gateway protocol version 2 exposes the definition and bounded
936
+ read-only records query:
937
+
938
+ ```ts
939
+ const leadPipeline: GatewayDatasetBinding = {
940
+ id: "lead-pipeline",
941
+ title: "Lead pipeline",
942
+ definition: leadPipelineDefinition,
943
+ authorization: {
944
+ organization: "required",
945
+ principalKinds: ["service_principal"],
946
+ scopes: ["datasets:read"]
947
+ },
948
+ query: async (input, context, { signal }) =>
949
+ queryLeadPipeline({ input, context, signal })
950
+ }
951
+ ```
952
+
953
+ Clients read `GET /v1/datasets/:id/definition` and submit
954
+ `POST /v1/datasets/:id/query`. A query carries the admitted definition revision,
955
+ source location, projection, ordering and row/byte/time bounds. The kernel
956
+ rejects stale revisions, unsupported aggregate or time-series operations,
957
+ undeclared fields and malformed results. Version 2 does not advertise Dataset cursor
958
+ pagination, predicates, grouping, aggregates or managed extraction.
959
+
960
+ ## CI-federated user authentication
961
+
962
+ Import `createFederatedAuth` from
963
+ `@collective-intelligence/external-apps-server-sdk/auth/federated`. Supply the
964
+ browser-visible HTTPS application origin, CI issuer, OAuth client, organization,
965
+ audience, scopes, and an application-owned atomic session store. The facade owns
966
+ PKCE login, callback, session inspection and logout under `/api/auth` by default.
967
+
968
+ The store persists only hashed opaque-cookie state, token grants and revisions;
969
+ the browser receives a `Secure`, `HttpOnly`, `SameSite=Lax` host cookie. Every
970
+ session lookup revalidates the short-lived CI identity. Revoked or expired CI
971
+ access ends application authorization on the next validation. A confidential
972
+ OAuth client may supply `clientSecret`; a public PKCE client omits it.
973
+
974
+ ### Forwarding a bound entity to CI
975
+
976
+ Import `defineCiEntityResource` from `/resources` and use it with the same
977
+ `createResourceHttpHandler` authorization callback as a local entity binding:
978
+
979
+ ```ts
980
+ const resource = defineCiEntityResource({
981
+ definition: leadDefinition,
982
+ tenantUrl: "https://ci.example.com",
983
+ organizationId,
984
+ schemaFqn
985
+ })
986
+ ```
987
+
988
+ The tenant HTTPS origin, organization, and schema FQN are server configuration.
989
+ Pass the original context returned by `auth.getEntityContext` to the handler;
990
+ copying or serializing it intentionally loses its private forwarding capability.
991
+ The verified grant stays server-side. CI rechecks current application/user policy
992
+ and owns canonical writes, revisions, and receipts; the gateway keeps no entity
993
+ replica. Keys, edited revisions, record IDs, payloads, and correlation IDs survive
994
+ the hop unchanged, so direct and proxied replay share the same CI receipt scope.
995
+ The adapter never retries or follows redirects. Cancellation or a lost response
996
+ after dispatch remains an uncertain outcome; retain the original action for
997
+ explicit resubmission.
998
+
999
+ ## Optional local user authentication
1000
+
1001
+ Import `createBetterAuth` only from
1002
+ `@collective-intelligence/external-apps-server-sdk/auth/better-auth` and install
1003
+ its optional `better-auth` peer (tested with 1.7.5). Supply the application's
1004
+ database and own its migrations and model names. Enable password login
1005
+ explicitly with `emailAndPassword: { enabled: true }` when required.
1006
+
1007
+ The adapter verifies sessions against SQL on every lookup; cookie session caches
1008
+ and secondary storage are disabled. `getSession` returns a session or `null`,
1009
+ and reports database outages through a typed unavailable error. Local user
1010
+ identity grants no CI organization or gateway machine authority. The CRM demo
1011
+ uses federated authentication.
1012
+
1013
+ ## Run the Effect CRM demo
1014
+
1015
+ The repository's [Effect CRM demo](../../../demo/external-app-effect/README.md)
1016
+ composes the published seams in one application: CI-federated login, shared
1017
+ Lead and Opportunity CRUD, a read-only Dataset, an idempotent conversion tool,
1018
+ durable connection grants, and an Effect server behind a TanStack Start client.
1019
+
1020
+ Follow its environment template, then start PostgreSQL, the server and the HTTPS
1021
+ client in separate terminals:
1022
+
1023
+ ```bash
1024
+ docker run --rm --name external-app-effect-db \
1025
+ -e POSTGRES_PASSWORD=postgres \
1026
+ -e POSTGRES_DB=external_app_effect \
1027
+ -p 127.0.0.1:55432:5432 postgres:17
1028
+
1029
+ cd demo/external-app-effect/apps/server
1030
+ set -a && source .env && set +a
1031
+ bun run dev
1032
+
1033
+ cd demo/external-app-effect/apps/client
1034
+ DEMO_APPLICATION_ORIGIN=https://external-app-effect.localhost \
1035
+ DEMO_SERVER_INTERNAL_ORIGIN=http://127.0.0.1:4118 \
1036
+ portless external-app-effect bun run dev
1037
+ ```
1038
+
1039
+ Open `https://external-app-effect.localhost`. Configure CI's reusable Gateway
1040
+ connection with `https://external-app-effect.localhost/gateway` and the same
1041
+ server-only setup token. The demo README lists the public Gateway and
1042
+ application routes and the package-specific verification commands.
1043
+
1044
+ ## Source layout
1045
+
1046
+ For maintainers, `src/entrypoints` mirrors every declared package export and is
1047
+ the only source of published JavaScript and declarations. Confidential CI
1048
+ clients live under `src/clients`; application login belongs to
1049
+ `src/application-auth`; gateway machine authorization, definitions, and HTTP
1050
+ routing stay under `src/gateway`; portable resource behavior stays under
1051
+ `src/resources`; and proxy policy stays under `src/proxy`.
1052
+
1053
+ Framework runtimes, provider mappings, persistence adapters, and private
1054
+ generated-client bridges are isolated under `src/adapters`. Optional peer
1055
+ dependencies must remain behind their matching runtime, persistence, or
1056
+ knowledge-source entrypoint. Add or remove public subpaths through
1057
+ `package.json`; the build derives its complete facade set from that export map
1058
+ and rejects missing entrypoints.
1059
+
1060
+ Each export also declares a `ci-source` condition that points to its TypeScript
1061
+ facade. Workspace consumers opt into that condition with TypeScript
1062
+ `customConditions`, Bun's `--conditions=ci-source`, or their bundler's
1063
+ equivalent.
1064
+ Without that explicit local condition, including in packed and published npm
1065
+ consumers, resolution continues to use the generated `dist` JavaScript and
1066
+ declarations.
1067
+
1068
+ ## Enum value compatibility
1069
+
1070
+ Repository-owned enum values use lower `snake_case`. Update gateway manifests,
1071
+ request payloads and saved resource filters together with their consumers. For
1072
+ example, resource filters use `starts_with` and `is_null`, lifecycle operation
1073
+ values use `soft_delete` and `hard_delete`, and gateway principal kinds use
1074
+ `detached_user` and `service_principal`. Public method and capability property
1075
+ names such as `softDelete` remain unchanged. External HTTP, OAuth, JSON Schema
1076
+ and provider values retain their required spelling at their adapter boundaries.