@collective-intelligence/external-apps-server-sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1075 -2
- package/dist/admin.d.ts +7556 -0
- package/dist/admin.js +6 -0
- package/dist/auth/better-auth.d.ts +71 -0
- package/dist/auth/better-auth.js +1 -0
- package/dist/auth/federated.d.ts +135 -0
- package/dist/auth/federated.js +8 -0
- package/dist/auth.d.ts +162 -0
- package/dist/auth.js +1 -0
- package/dist/express.d.ts +644 -0
- package/dist/express.js +1 -0
- package/dist/gateway/contracts.d.ts +663 -0
- package/dist/gateway/contracts.js +0 -0
- package/dist/gateway.d.ts +762 -0
- package/dist/gateway.js +8 -0
- package/dist/index.d.ts +597 -0
- package/dist/index.js +9 -0
- package/dist/knowledge-sources/payload.d.ts +420 -0
- package/dist/knowledge-sources/payload.js +1 -0
- package/dist/knowledge-sources/strapi.d.ts +389 -0
- package/dist/knowledge-sources/strapi.js +1 -0
- package/dist/persistence/drizzle-effect.d.ts +38 -0
- package/dist/persistence/drizzle-effect.js +1 -0
- package/dist/persistence/drizzle.d.ts +1514 -0
- package/dist/persistence/drizzle.js +18 -0
- package/dist/resources.d.ts +423 -0
- package/dist/resources.js +15 -0
- package/dist/runtime/effect.d.ts +954 -0
- package/dist/runtime/effect.js +15 -0
- package/dist/runtime/express.d.ts +644 -0
- package/dist/runtime/express.js +1 -0
- package/dist/runtime/nestjs.d.ts +802 -0
- package/dist/runtime/nestjs.js +8 -0
- package/dist/runtime/next.d.ts +874 -0
- package/dist/runtime/next.js +8 -0
- package/dist/runtime/web.d.ts +776 -0
- package/dist/runtime/web.js +8 -0
- package/dist/tools/ai-sdk.d.ts +170 -0
- package/dist/tools/ai-sdk.js +8 -0
- package/package.json +242 -4
package/README.md
CHANGED
|
@@ -1,3 +1,1076 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@collective-intelligence/external-apps-server-sdk`
|
|
2
2
|
|
|
3
|
-
|
|
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.
|