@gnldev/server 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/LICENSE +201 -0
- package/README.md +181 -0
- package/dist/edge-errors.d.ts +32 -0
- package/dist/edge-errors.js +71 -0
- package/dist/edge-errors.js.map +1 -0
- package/dist/handler.d.ts +65 -0
- package/dist/handler.js +39 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +222 -0
- package/dist/index.js +2121 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +35 -0
- package/dist/node.js +94 -0
- package/dist/node.js.map +1 -0
- package/dist/openapi.d.ts +1 -0
- package/dist/openapi.js +215 -0
- package/dist/openapi.js.map +1 -0
- package/dist/sse.d.ts +57 -0
- package/dist/sse.js +228 -0
- package/dist/sse.js.map +1 -0
- package/package.json +71 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { type FetchHandler } from './handler.js';
|
|
2
|
+
import type { CreateGnlConfig, BudgetLimit, RunLimits } from '@gnldev/durable';
|
|
3
|
+
import { type AuthProvider, type ReadWriteAuth, type Principal } from '@gnldev/auth';
|
|
4
|
+
/**
|
|
5
|
+
* D4-FGA (EE-2): structural mirror of @gnldev/auth-ee's `FgaResource`/`FgaAction` — @gnldev/server does NOT
|
|
6
|
+
* depend on the paid @gnldev/auth-ee package, so these are typed here rather than imported (see
|
|
7
|
+
* `RestApiOptions.resourceAuth` below).
|
|
8
|
+
*/
|
|
9
|
+
export interface ResourceAuthResource {
|
|
10
|
+
type: 'agent' | 'workflow' | 'tool' | 'run';
|
|
11
|
+
id: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The verbs @gnldev/server ACTUALLY dispatches, and the ones it does not.
|
|
15
|
+
*
|
|
16
|
+
* DISPATCHED: `'run'` (agent run/stream/resume, workflow run) and `'cancel'` (run/workflow cancel).
|
|
17
|
+
*
|
|
18
|
+
* NOT DISPATCHED: `'read'`. It is kept in the union because the union is open (`string & {}`) and a
|
|
19
|
+
* host may dispatch its own verbs — but nothing in this package ever calls the gate with it, so a
|
|
20
|
+
* host that writes an `action === 'read'` branch gets a rule that never runs. That was measured as a
|
|
21
|
+
* real hazard rather than a cosmetic one: a signature that names a verb reads as a promise that the
|
|
22
|
+
* verb is checked, and the read paths are exactly where subject binding was missing
|
|
23
|
+
* (`subjectBinding`). Read authorisation lives there, not here.
|
|
24
|
+
*
|
|
25
|
+
* `'resume'` IS dispatched — but as `'run'`: resuming executes the agent and carries `approvals`,
|
|
26
|
+
* so denying `run` while allowing `resume` would be the wrong way round (see the resume endpoint's
|
|
27
|
+
* own note). The member stays for hosts that want to distinguish them in their own dispatch.
|
|
28
|
+
*/
|
|
29
|
+
export type ResourceAuthAction = 'run' | 'read' | 'cancel' | 'resume' | (string & {});
|
|
30
|
+
/** Per-request multi-organization (opt-in): resolve organization from the request → journal is scoped to that organization. */
|
|
31
|
+
export interface OrgOptions {
|
|
32
|
+
/** Resolve the organization from the request. If not given, the `x-gnl-org` header is read. */
|
|
33
|
+
/**
|
|
34
|
+
* Takes a web `Request`, not a Hono `Context` — kept in step with @gnldev/studio, and for the same
|
|
35
|
+
* reason: a host binding this handler from Express or Fastify has a Request and no Context.
|
|
36
|
+
*/
|
|
37
|
+
resolve?: (req: Request) => string | undefined | Promise<string | undefined>;
|
|
38
|
+
/** true → a request without an organization gets 400. false (default) → a request without an organization runs in the shared scope. */
|
|
39
|
+
required?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* HARDENING (opt-in): reject any request whose resolved organization is NOT explicitly REGISTERED
|
|
42
|
+
* (an `__org__:<id>` record written by studio's `POST /organizations`; a DELETED org is a `null`
|
|
43
|
+
* tombstone → also rejected → its ghost tokens stop working). Default false = the legacy IMPLICIT-org
|
|
44
|
+
* behavior (an org springs into existence on first activity, no registration needed) — byte-for-byte
|
|
45
|
+
* unchanged. Turn ON for strict provisioning: only orgs you deliberately created may run. NOTE: requires
|
|
46
|
+
* a registration PATH in the deployment (studio's org management, or a direct `__org__:<id>` write);
|
|
47
|
+
* with it on and no such path, every org-scoped request is rejected. */
|
|
48
|
+
requireRegistration?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Ceiling on how many per-organization registries are held in memory at once (default 512).
|
|
51
|
+
*
|
|
52
|
+
* The cache is keyed by the RESOLVED organization id, and with `requireRegistration` off — the
|
|
53
|
+
* default — that id is whatever `resolve` returned, i.e. the `x-gnl-org` header. Every distinct
|
|
54
|
+
* value built a full `createGnl` registry and kept it forever, so a loop over random header values
|
|
55
|
+
* grew the process until it died. Nothing in the request has to be valid for that: an unregistered
|
|
56
|
+
* org is served, and serving it is what allocates.
|
|
57
|
+
*
|
|
58
|
+
* Least-recently-used entries are evicted past the cap. Eviction is state-preserving, and the parts
|
|
59
|
+
* that could have made it otherwise were measured rather than assumed: an instance is a CACHE over
|
|
60
|
+
* the org-scoped journal (memory comes from `memoryFactory(scoped)`, the frozen model spec lives in
|
|
61
|
+
* the journal), `createGnl` opens no connections and starts no timers, the rebuild re-derives
|
|
62
|
+
* `withOrg(base, id)` without double-scoping the key prefix, and cancellation is unaffected because
|
|
63
|
+
* `inflight` hangs off the API closure keyed `org:<id>:<runId>` — not off the instance — so the key
|
|
64
|
+
* survives a rebuild.
|
|
65
|
+
*
|
|
66
|
+
* The one exception is a `memoryFactory` that IGNORES its argument and returns a process-local store.
|
|
67
|
+
* That is a split rather than a loss: an in-flight run holds the evicted instance by reference, so
|
|
68
|
+
* for a window one organization has two live stores and half a conversation lands in the one nothing
|
|
69
|
+
* will read again. A factory that uses the journal it is handed — which is why it is handed one —
|
|
70
|
+
* has no such window.
|
|
71
|
+
*
|
|
72
|
+
* Raise it if you legitimately serve more than 512 organizations from one process and want them all
|
|
73
|
+
* warm. A non-integer or a value below 1 is REJECTED at construction, `Infinity` included: the growth
|
|
74
|
+
* this bounds is reachable by anyone who can send a header, so opting out of the bound is not a
|
|
75
|
+
* setting, and a negative one used to spin the eviction loop forever.
|
|
76
|
+
*/
|
|
77
|
+
maxInstances?: number;
|
|
78
|
+
}
|
|
79
|
+
export interface RestApiOptions {
|
|
80
|
+
/** OpenAPI title. */
|
|
81
|
+
title?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Optional auth (opt-in). If not given, all endpoints are OPEN (existing behavior). Accepts an
|
|
84
|
+
* AuthProvider (@gnldev/auth or the paid @gnldev/auth-ee) or a backward-compatible {read,write} predicate pair.
|
|
85
|
+
* GET → read, POST → write.
|
|
86
|
+
*/
|
|
87
|
+
auth?: AuthProvider | ReadWriteAuth;
|
|
88
|
+
/**
|
|
89
|
+
* Who a request is allowed to speak FOR, on the read paths.
|
|
90
|
+
*
|
|
91
|
+
* `'declared'` (default, today's behaviour): the expectation comes only from what the CALLER
|
|
92
|
+
* states (`?resourceId=` or the body). State nobody and nothing is checked — the rule was written
|
|
93
|
+
* for operators, who work across an organization by design and name nobody.
|
|
94
|
+
*
|
|
95
|
+
* `'strict'`: an authenticated NON-OPERATOR identity speaks for ITSELF. `principal.id` becomes the
|
|
96
|
+
* expectation and a caller-supplied name cannot replace it.
|
|
97
|
+
*
|
|
98
|
+
* WHY THE OPTION EXISTS RATHER THAN A STRAIGHT FIX. Measured on a deployment that hands user-store
|
|
99
|
+
* tokens to end users: `mallory` read `GET /threads` (the whole subject inventory), then
|
|
100
|
+
* `/threads/t-ayse/messages` (`AYSE-SECRET`), then `/runs/r-ayse` (the full journal) — all 200,
|
|
101
|
+
* and naming someone else explicitly was 200 as well. The same identity is a SUBJECT when it
|
|
102
|
+
* writes (`resolveResourceId`) and an OPERATOR when it reads; that asymmetry is the hole.
|
|
103
|
+
*
|
|
104
|
+
* It is not flipped by default because doing so turns today's 200s into 403s for every deployment
|
|
105
|
+
* whose operators read across their organization — which is the documented, intended use. The flag
|
|
106
|
+
* lets a deployment that gives end users tokens close the hole now; the default follows once the
|
|
107
|
+
* operator/end-user split has a first-class shape.
|
|
108
|
+
*/
|
|
109
|
+
subjectBinding?: 'declared' | 'strict';
|
|
110
|
+
/**
|
|
111
|
+
* DELIBERATE permission for a provider-less API in production. Auth remains opt-in; but in
|
|
112
|
+
* NODE_ENV=production, calling createRestApi without `auth` throws a setup ERROR — silent fail-open is
|
|
113
|
+
* disabled (audit #2). Set this flag to true if open access is genuinely intended. Outside production:
|
|
114
|
+
* only a single console.warn on the first request.
|
|
115
|
+
*/
|
|
116
|
+
allowOpenAccess?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Opt-in multi-organization support: each request descends into that organization's journal (withOrg)
|
|
119
|
+
* → runs, memory, and exactly-once guarantees are isolated per organization. The resolved organization
|
|
120
|
+
* is injected into requestContext as `org` (visible to dynamic agents). The registry is lazily built
|
|
121
|
+
* and cached per organization.
|
|
122
|
+
*/
|
|
123
|
+
org?: OrgOptions;
|
|
124
|
+
/**
|
|
125
|
+
* Budget/quota FALLBACK limits (ENFORCED on the write path): an organization's effective limit is
|
|
126
|
+
* journal's `__budget__:<id>`/`__budget__:default` (managed from Studio) > `perOrg[id]` > `default`.
|
|
127
|
+
* A new run/stream/workflow request from an organization that is over budget gets 402 (resume remains
|
|
128
|
+
* free — suspended work can finish). Even without this option, budgets written to the journal are
|
|
129
|
+
* enforced (no limit → cost-free early exit).
|
|
130
|
+
*/
|
|
131
|
+
budgets?: {
|
|
132
|
+
default?: BudgetLimit;
|
|
133
|
+
perOrg?: Record<string, BudgetLimit>;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* SERVER-SIDE UPPER BOUND (opt-in) for per-run cost cap + loop detection. The `limits` in the
|
|
137
|
+
* request body (client request) CANNOT EXCEED this cap — the effective limit for each field is computed
|
|
138
|
+
* as `min(server, client)` (see `clampLimits`); if the client doesn't specify a field, the server cap
|
|
139
|
+
* applies, and if neither server nor client specifies it, that field is never enforced. If neither is
|
|
140
|
+
* given (default), behavior is preserved EXACTLY AS IS (unlimited).
|
|
141
|
+
*/
|
|
142
|
+
limits?: RunLimits;
|
|
143
|
+
/**
|
|
144
|
+
* (audit: A2A unsigned) — opt-in A2A request verification: if given, the `x-gnl-signature`/
|
|
145
|
+
* `x-gnl-timestamp` header pair produced by `@gnldev/a2a`'s `createA2ATool({ secret })` becomes REQUIRED on
|
|
146
|
+
* `/agents/:name/run` POSTs (see verifyA2ASignature) — missing/wrong signature or a timestamp outside
|
|
147
|
+
* ±300s → 401. If not given, behavior is preserved EXACTLY AS IS (unsigned requests are accepted as
|
|
148
|
+
* before). Works TOGETHER WITH the existing auth gate (opts.auth) — the two are independent layers,
|
|
149
|
+
* both must pass.
|
|
150
|
+
*/
|
|
151
|
+
a2aSecret?: string;
|
|
152
|
+
/**
|
|
153
|
+
* D4-FGA (EE-2) opt-in hook: fine-grained (resource-scoped) authorization, consulted AFTER the existing
|
|
154
|
+
* coarse gate (opts.auth) already allowed the request — on agents run/stream, workflows run, and the
|
|
155
|
+
* two cancel endpoints (`/runs/:id/cancel`, `/workflows/runs/:id/cancel`). A denial → 403
|
|
156
|
+
* `{error, code: 'resource_denied'}`, distinct from the coarse gate's 403 (which carries no `code`).
|
|
157
|
+
* Structurally typed (`ResourceAuthResource`/`ResourceAuthAction` above) — @gnldev/server does NOT import
|
|
158
|
+
* @gnldev/auth-ee; an EE user wires this to `createEnterpriseAuth(...).checkResource` (see @gnldev/auth-ee's
|
|
159
|
+
* `createFga`/`EnterpriseAuthProvider.checkResource`), e.g.:
|
|
160
|
+
* resourceAuth: (p, r, a) => enterpriseAuth.checkResource!(p, r, a).then((res) => res.allowed)
|
|
161
|
+
* If NOT given, behavior is preserved EXACTLY AS IS (no resource-level gate — existing coarse auth only).
|
|
162
|
+
*/
|
|
163
|
+
resourceAuth?: (principal: Principal | null, resource: ResourceAuthResource, action: ResourceAuthAction) => Promise<boolean> | boolean;
|
|
164
|
+
/**
|
|
165
|
+
* Agent approval registry (opt-in governance gate, default false — BACKWARD COMPAT: existing
|
|
166
|
+
* deployments are byte-for-byte unchanged). When true, run/resume/stream ALSO require the target
|
|
167
|
+
* agent to be `approved` in the journal-backed registry (@gnldev/durable's `isAgentServable`) — a
|
|
168
|
+
* pending/changed/blocked agent gets 403 `{error, code: 'agent_not_approved'}`. Every `config.agents`
|
|
169
|
+
* entry is recorded (idempotent, fingerprinted) once at construction so a platform-admin can review
|
|
170
|
+
* it via `GET /agents/registry` and approve/block it (see the `/agents/registry*` endpoints below) —
|
|
171
|
+
* those endpoints are ALWAYS available (regardless of this flag) so approval can be set up ahead of
|
|
172
|
+
* turning the gate on.
|
|
173
|
+
*/
|
|
174
|
+
requireAgentApproval?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* The startup protection matrix (default ON). Set false when something ELSE is already printing
|
|
177
|
+
* one for this config.
|
|
178
|
+
*
|
|
179
|
+
* That is not hypothetical: `gnl dev` mounts this host and prints its own matrix, and its version
|
|
180
|
+
* knows one thing this one cannot — that the dev server DERIVES a memory store the project's
|
|
181
|
+
* `src/app.ts` will not have. Two blocks describing the same config, one of them less informed, is
|
|
182
|
+
* how a reader learns to skip both. This flag exists so there is exactly one.
|
|
183
|
+
*
|
|
184
|
+
* It is an opt-OUT rather than an opt-in on purpose: the matrix is most needed by the deployment
|
|
185
|
+
* that has not thought about any of these rows.
|
|
186
|
+
*/
|
|
187
|
+
protectionsBanner?: boolean;
|
|
188
|
+
}
|
|
189
|
+
/** Leak-free metadata from agent records (the model object is hidden → only string id / 'custom'). */
|
|
190
|
+
export interface AgentMeta {
|
|
191
|
+
name: string;
|
|
192
|
+
model: string;
|
|
193
|
+
system?: string;
|
|
194
|
+
hasTools: boolean;
|
|
195
|
+
maxSteps: number;
|
|
196
|
+
/** Orgs this org-scoped agent belongs to (UI label); undefined for global agents. */
|
|
197
|
+
orgs?: string[];
|
|
198
|
+
}
|
|
199
|
+
export type { FetchHandler, RouteInfo } from './handler.js';
|
|
200
|
+
export { buildOpenApi } from './openapi.js';
|
|
201
|
+
export { EDGE_ERROR_CODES, type EdgeErrorCode } from './edge-errors.js';
|
|
202
|
+
export { pipeAgentStream, interruptsFromSteps, sseResponse } from './sse.js';
|
|
203
|
+
/**
|
|
204
|
+
* The REST API as a fetch handler.
|
|
205
|
+
*
|
|
206
|
+
* Hono host: app.mount('/api', createRestApi(config))
|
|
207
|
+
* Anything else: bridge it (see @gnldev/studio/node for the same job on the Studio side)
|
|
208
|
+
* Standalone: serve({ fetch: createRestApi(config).fetch })
|
|
209
|
+
*
|
|
210
|
+
* The `run?: never; agent?: never` is a guard, not decoration. Every field of `CreateGnlConfig` is
|
|
211
|
+
* optional, so ANY object satisfies it — including the REGISTRY that `createGnl(config)` returns,
|
|
212
|
+
* which is the one thing callers reach for by mistake (`createRestApi(gnl)` instead of
|
|
213
|
+
* `createRestApi(config)`). It typechecked, started, and then answered every request from an empty
|
|
214
|
+
* config: no agents, no journal, and nothing anywhere saying so. `run` and `agent` exist on the
|
|
215
|
+
* registry and on no config, so naming them `never` rejects exactly that object and costs a real
|
|
216
|
+
* config nothing. Documented because it was shipped wrong in the guide first, and because a
|
|
217
|
+
* signature that looks decorative is the kind that gets "simplified" away.
|
|
218
|
+
*/
|
|
219
|
+
export declare function createRestApi(config: CreateGnlConfig & {
|
|
220
|
+
run?: never;
|
|
221
|
+
agent?: never;
|
|
222
|
+
}, opts?: RestApiOptions): FetchHandler;
|