@nightfuel-ai/app-sdk 0.1.2 → 0.6.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 +312 -0
- package/events.d.ts +59 -0
- package/events.js +188 -0
- package/index.d.ts +54 -2
- package/index.js +55 -1
- package/item-writes.js +57 -0
- package/items.d.ts +66 -0
- package/items.js +112 -0
- package/maps.js +36 -0
- package/mcp.js +25 -0
- package/package.json +10 -1
- package/resources.d.ts +58 -0
- package/resources.js +125 -0
package/README.md
CHANGED
|
@@ -133,3 +133,315 @@ workflow, workflow version, or user identity.
|
|
|
133
133
|
The managed Sandbox starter is a Vite frontend, not a production backend: it
|
|
134
134
|
uses the platform's same-origin preview proxies and does not receive this SDK
|
|
135
135
|
in browser source.
|
|
136
|
+
# Typed App events (On App)
|
|
137
|
+
|
|
138
|
+
Declare events in server code with `defineAppEvents` and `eventSchema`. The same
|
|
139
|
+
definition supplies TypeScript payload inference and a bounded JSON Schema
|
|
140
|
+
manifest. It is compatible with CommonJS and native ESM.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { defineAppEvents, eventSchema as s, createNightfuelAppSdk } from '@nightfuel-ai/app-sdk';
|
|
144
|
+
import { writeFileSync } from 'node:fs';
|
|
145
|
+
|
|
146
|
+
export const events = defineAppEvents({
|
|
147
|
+
'booking.created': {
|
|
148
|
+
version: 1,
|
|
149
|
+
title: 'Booking created',
|
|
150
|
+
payload: s.object({
|
|
151
|
+
bookingId: s.string({ minLength: 1 }),
|
|
152
|
+
propertyId: s.string({ minLength: 1 }),
|
|
153
|
+
nights: s.integer({ minimum: 1 }),
|
|
154
|
+
channel: s.enum('site', 'whatsapp'),
|
|
155
|
+
}),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
// Run in a build/generation script and commit the generated file.
|
|
159
|
+
writeFileSync('.nightfuel/events.json', JSON.stringify(events.manifest, null, 2) + '\n');
|
|
160
|
+
const nf = createNightfuelAppSdk({ events });
|
|
161
|
+
await nf.events.emit('booking.created', {
|
|
162
|
+
bookingId: 'b1', propertyId: 'hotel-1', nights: 3, channel: 'site',
|
|
163
|
+
}, {
|
|
164
|
+
idempotencyKey: 'booking-b1-created-v1',
|
|
165
|
+
occurredAt: '2026-10-20T10:00:00.000Z',
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
NightFUEL indexes `.nightfuel/events.json` while ingesting source. It does not
|
|
170
|
+
execute code or request a discovery endpoint on the App. The workflow editor
|
|
171
|
+
and `hosting_app_event_catalog_list` select the exact App/environment, event
|
|
172
|
+
path, immutable version and hash from platform storage. Previously registered
|
|
173
|
+
versions remain available for pinned workflows. Increment `version` whenever
|
|
174
|
+
the payload schema changes; reusing a version with a different schema is rejected.
|
|
175
|
+
|
|
176
|
+
`events.emit` is server-only and uses the App's workload identity. It validates
|
|
177
|
+
the payload before sending it; the platform validates it again. A `202` receipt
|
|
178
|
+
means durable acceptance, not workflow completion. Keep the same idempotency key,
|
|
179
|
+
occurrence time and payload on retries. Reusing a key for a different event
|
|
180
|
+
returns `409`. Mutating requests are not automatically retried by the SDK.
|
|
181
|
+
|
|
182
|
+
For business mutations, commit an outbox row in the same database transaction
|
|
183
|
+
as the mutation. A worker sends the persisted event and marks the row delivered
|
|
184
|
+
only after acceptance. The App being off prevents new emissions; it does not
|
|
185
|
+
remove discovery or interrupt delivery of events already accepted by NightFUEL.
|
|
186
|
+
An in-memory queue or fire-and-forget call is not a transactional outbox.
|
|
187
|
+
|
|
188
|
+
Schemas support closed objects, optional properties, arrays, homogeneous enums,
|
|
189
|
+
strings (including `date`, `date-time`, `uuid`), numbers, integers and booleans,
|
|
190
|
+
with bounded lengths and numeric ranges. Root payloads must be objects.
|
|
191
|
+
Manifests and emission envelopes are limited to 256 KiB; each manifest may
|
|
192
|
+
declare at most 100 event paths. Unsupported JSON Schema features are rejected.
|
|
193
|
+
|
|
194
|
+
## App credentials, Calendar and durable callbacks
|
|
195
|
+
|
|
196
|
+
Version `0.3.0` adds App-scoped access to the existing project Vault and Calendar.
|
|
197
|
+
All calls use the server workload identity; the App cannot select a different
|
|
198
|
+
App or project. These are runtime credentials, not deployment environment
|
|
199
|
+
variables: saving or rotating one never starts a build.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
const reference = { namespace: 'stripe_test', scope: 'hotel_1', name: 'api' };
|
|
203
|
+
await sdk.credentials.store({ ...reference, value: serverSideInput });
|
|
204
|
+
const { value } = await sdk.credentials.read(reference);
|
|
205
|
+
// Use only in the server provider adapter; never return or log `value`.
|
|
206
|
+
await sdk.credentials.remove(reference);
|
|
207
|
+
|
|
208
|
+
await sdk.calendar.connect({
|
|
209
|
+
id: 'airbnb_room_1', scope: 'hotel_1', resourceId: 'room_1',
|
|
210
|
+
name: 'Airbnb', url: privateIcalUrl,
|
|
211
|
+
});
|
|
212
|
+
const snapshot = await sdk.calendar.snapshot({ id: 'airbnb_room_1', scope: 'hotel_1' });
|
|
213
|
+
await sdk.calendar.upsert({
|
|
214
|
+
id: 'booking_1', scope: 'hotel_1', resourceId: 'room_1',
|
|
215
|
+
from: '2026-11-01', to: '2026-11-03', title: 'Reserved',
|
|
216
|
+
version: 1, active: true,
|
|
217
|
+
});
|
|
218
|
+
await sdk.jobs.ensure({
|
|
219
|
+
key: 'maintenance', path: '/api/internal/sync', everySeconds: 60,
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Calendar stores encrypted source URLs, validates whole Airbnb availability
|
|
224
|
+
feeds and preserves the last snapshot after failure. Dates are civil days with
|
|
225
|
+
an exclusive end. The export URL is a private bearer capability: show it only to
|
|
226
|
+
authorized operators. Exports include the App's active projections, never
|
|
227
|
+
imported blocks or guest data. Increasing versions and `active: false` tombstones
|
|
228
|
+
prevent delayed retries from restoring cancelled intervals.
|
|
229
|
+
|
|
230
|
+
The platform worker synchronizes sources independently of the App and invokes
|
|
231
|
+
registered callbacks with Cloud Run identity and `X-NF-App-Call`, including
|
|
232
|
+
after scale-to-zero. Verify the exact App, method, path and raw body with
|
|
233
|
+
`verifyNightfuelCall`. Jobs are at-least-once; handlers must be idempotent and
|
|
234
|
+
bounded. Integration requests and event emissions time out after 15 seconds.
|
|
235
|
+
|
|
236
|
+
## Typed resources for Workflow Loader
|
|
237
|
+
|
|
238
|
+
Apps can expose business objects independently of events. Publish the schema
|
|
239
|
+
from server code; each version is immutable and every item has a required string
|
|
240
|
+
`id`. The schema dialect and limits are the same as typed events.
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { defineAppResources, resourceSchema as s, createAppResourceHandler } from '@nightfuel-ai/app-sdk';
|
|
244
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
245
|
+
|
|
246
|
+
export const resources = defineAppResources([{
|
|
247
|
+
key: 'kitchen.ingredient',
|
|
248
|
+
version: 1,
|
|
249
|
+
title: 'Ingredient',
|
|
250
|
+
item: s.object({
|
|
251
|
+
id: s.string({ minLength: 1 }),
|
|
252
|
+
name: s.string(),
|
|
253
|
+
quantity: s.number({ minimum: 0 }),
|
|
254
|
+
unit: s.enum('kg', 'l'),
|
|
255
|
+
}),
|
|
256
|
+
}]);
|
|
257
|
+
// In a generation script: commit this file with the App's source.
|
|
258
|
+
mkdirSync('.nightfuel', { recursive: true });
|
|
259
|
+
writeFileSync('.nightfuel/resources.json', JSON.stringify(resources.manifest, null, 2) + '\n');
|
|
260
|
+
|
|
261
|
+
// In the App server. `policy` and `ingredients` are your own business adapters.
|
|
262
|
+
const readResource = createAppResourceHandler(resources, {
|
|
263
|
+
authorize: ({ principal, itemId }) => policy.canReadIngredient(principal, itemId),
|
|
264
|
+
readers: [{
|
|
265
|
+
key: 'kitchen.ingredient', version: 1,
|
|
266
|
+
read: ({ itemId }) => ingredients.findPublicProjection(itemId), // typed item or null
|
|
267
|
+
}],
|
|
268
|
+
});
|
|
269
|
+
// Register before express.json() so signature verification receives exact bytes.
|
|
270
|
+
app.post('/_nightfuel/resources/read', express.raw({ type: 'application/json', limit: '256kb' }), async (req, res) => {
|
|
271
|
+
try {
|
|
272
|
+
const reply = await readResource({
|
|
273
|
+
headers: req.headers, method: req.method, path: req.path, rawBody: req.body,
|
|
274
|
+
});
|
|
275
|
+
res.json(reply);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
res.status(error.statusCode || error.status || 500).json({ error: 'Resource unavailable' });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The handler uses server-only `NF_APP_ID` and `NF_APP_CALL_SECRET` supplied by
|
|
283
|
+
the platform (or explicit `appId`/`secret` options). Do not include them in
|
|
284
|
+
browser bundles. Resource reads use a separate signing purpose: an ordinary
|
|
285
|
+
Apphook token, even for the same URL and body, is not sufficient.
|
|
286
|
+
|
|
287
|
+
`authorize` is mandatory and runs before `read`. It receives the authenticated
|
|
288
|
+
`principal: { type, id, workspaceId, projectId }`; types are `user`, `agent`,
|
|
289
|
+
`workflow` and `mcp_connection`. A published workflow uses its own identity,
|
|
290
|
+
not the publisher's. Implement record-level policy in the App: platform App
|
|
291
|
+
visibility is only the first access check. Readers must have no side effects.
|
|
292
|
+
|
|
293
|
+
Use `null` for authorized absence. Wrong IDs, undeclared fields, missing required
|
|
294
|
+
fields and wrong types are rejected. The platform validates the response again.
|
|
295
|
+
To change the schema, publish a new version and keep readers for old versions
|
|
296
|
+
while workflows still use them. Discovery never calls the App or executes its
|
|
297
|
+
code; normal source ingestion indexes the manifest for the native Loader and
|
|
298
|
+
MCP catalog. Selecting a version pins its exact hash and never follows “latest”.
|
|
299
|
+
|
|
300
|
+
The first contract supports one item by ID. It grants no arbitrary database
|
|
301
|
+
querying, source access or automatic synchronization. Explicit workflows can map
|
|
302
|
+
the result to native Pipeline Edit operations with `expectedRevision`.
|
|
303
|
+
|
|
304
|
+
## Partial projection into native Items
|
|
305
|
+
|
|
306
|
+
Use `items.sync` when a small, durable projection should appear in native Lists
|
|
307
|
+
and Calendar even while the App is offline. Keep operational records in the
|
|
308
|
+
App's database; do not copy a guest history or credentials into an Item.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
import { createNightfuelAppSdk, defineItemProjection } from '@nightfuel-ai/app-sdk';
|
|
312
|
+
const bookings = defineItemProjection({
|
|
313
|
+
key: 'bookings', title: 'Reservations', fields: {
|
|
314
|
+
state: { type: 'string', label: 'Status' },
|
|
315
|
+
arrival: { type: 'datetime', label: 'Stay', calendar: { endField: 'departure' } },
|
|
316
|
+
departure: { type: 'datetime', label: 'Departure' },
|
|
317
|
+
reference: { type: 'json', label: 'Reference' },
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
const sdk = createNightfuelAppSdk();
|
|
321
|
+
await sdk.items.sync(bookings, {
|
|
322
|
+
scope: 'hotel_centro', id: 'booking_123', version: 2, title: 'Reservation ABC',
|
|
323
|
+
fields: {
|
|
324
|
+
state: 'confirmed', arrival: '2026-11-01T14:00:00-03:00',
|
|
325
|
+
departure: '2026-11-03T12:00:00-03:00', reference: { adults: 2 },
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The platform creates a native, project-local definition and collection per
|
|
331
|
+
App/scope/key. The stable external ID binds exactly one native Item; callers
|
|
332
|
+
cannot supply a project, pipeline or Item ID to overwrite another resource.
|
|
333
|
+
Fields are typed as string, integer, number, boolean, date, datetime or JSON.
|
|
334
|
+
Only datetime fields explicitly declaring a calendar interval appear in the
|
|
335
|
+
Calendar. Dates nested in JSON do not become calendar events.
|
|
336
|
+
|
|
337
|
+
Sync is a partial field update: omitted fields keep their values, `null` clears
|
|
338
|
+
them. Extra native fields and tags are preserved. The App owns the title and
|
|
339
|
+
declared fields; fields are read-only unless explicitly marked `editable: true`
|
|
340
|
+
with a signed write handler (below). Business validation stays in the App. A new source version is required for every
|
|
341
|
+
changed payload. Same-version retries are idempotent; older versions are ignored;
|
|
342
|
+
same-version different contents return 409. Removing or disabling a native
|
|
343
|
+
collection/Item does not silently recreate it. Contract changes and native
|
|
344
|
+
definition drift return 409 and require an explicit migration.
|
|
345
|
+
|
|
346
|
+
Persist a minimal snapshot in an outbox **in the same transaction as the domain
|
|
347
|
+
change**, then sync after commit. The SDK rejects errors; it does not conceal
|
|
348
|
+
failed deliveries. Keep the snapshot/version for retry, surface pending failures,
|
|
349
|
+
and use `jobs.ensure` for callbacks that wake an App scaled to zero. State
|
|
350
|
+
projections may coalesce newer versions; use On App events for every transition.
|
|
351
|
+
|
|
352
|
+
`availability: { events: [{ id, resourceId, from, to }], replaceEventIds?: [] }`
|
|
353
|
+
optionally supplies a full snapshot of date-only, exclusive-end intervals for
|
|
354
|
+
existing iCal exports. Empty events clear them; omission leaves them alone. The
|
|
355
|
+
platform writes these intervals and the Item atomically and renders only the Item
|
|
356
|
+
in its Calendar. `replaceEventIds` explicitly adopts old `calendar.upsert` rows
|
|
357
|
+
owned by the same App/scope. Imported external blocks are not re-exported.
|
|
358
|
+
|
|
359
|
+
### Editing native Items through the App
|
|
360
|
+
|
|
361
|
+
Declare `writePath: '/api/internal/items/write'`, `editable: true` on the selected
|
|
362
|
+
fields, and optional `stages: [{ key, label, next: ['next_stage'] }]` in
|
|
363
|
+
`defineItemProjection`. The first stage is initial; every sync must include a
|
|
364
|
+
declared `stageKey` when stages exist. Only allowed transitions appear in the
|
|
365
|
+
native contract. Fields and stage edits from the editor, REST, MCP and workflows
|
|
366
|
+
use the same native mutation boundary.
|
|
367
|
+
|
|
368
|
+
Register `createItemWriteHandler(definition, { authorize, write, secret?, appId? })`
|
|
369
|
+
at `writePath` and pass the raw POST bytes, headers, method and path. `authorize`
|
|
370
|
+
is mandatory: map the verified native principal to the App's explicit record and
|
|
371
|
+
command policy. A native Item edit is not an App administrator login. The
|
|
372
|
+
platform checks current Item edit permission, Item/container scope and App view
|
|
373
|
+
scope before sending and on every durable retry. User roles are not copied.
|
|
374
|
+
|
|
375
|
+
The typed writer receives `scope`, `id`, `expectedVersion`, `mutationId`,
|
|
376
|
+
`principal` and `changes: { fields, stageKey? }`. Only editable field keys are
|
|
377
|
+
inferred. Execute domain commands and store a receipt keyed by `mutationId`
|
|
378
|
+
**in the same transaction**. Replays must return the exact receipt before
|
|
379
|
+
checking the old source version. Return a canonical `ItemSyncInput` with a newer
|
|
380
|
+
version, the complete authoritative projection and no private domain records.
|
|
381
|
+
Use the same projection payload in the App outbox. The SDK validates the reply;
|
|
382
|
+
the platform syncs it before acknowledging success. No local Item overwrite can
|
|
383
|
+
bypass the App, including callers that forge an `origin` or request derived writes.
|
|
384
|
+
|
|
385
|
+
Generic Apphook signatures cannot invoke this writer: it uses a separate HMAC
|
|
386
|
+
purpose with the App's existing call secret. It is not necessary to create or
|
|
387
|
+
store another API key. Native date Fields are converted from UTC-midnight
|
|
388
|
+
serialization to civil `YYYY-MM-DD` values before reaching the writer.
|
|
389
|
+
|
|
390
|
+
Only return `{ rejected: true, error: { code, message } }` with HTTP 400/403/404/409
|
|
391
|
+
when the domain transaction definitively rejected the command. Bound error text
|
|
392
|
+
is displayed to the editor. Timeouts, invalid responses and other HTTP failures
|
|
393
|
+
remain pending. The platform persists the intent, retains the old Item until
|
|
394
|
+
confirmation, and retries with the same mutation ID; HTTP invocation wakes a
|
|
395
|
+
scaled-to-zero App. `APP_ITEM_WRITE_PENDING` tells the caller to refresh later.
|
|
396
|
+
|
|
397
|
+
Save unrelated native fields/tags separately from App fields. Single-Item stage
|
|
398
|
+
changes are supported; a multi-Item batch is rejected instead of claiming a
|
|
399
|
+
distributed atomic transaction. Create records through the App/MCP: ordinary
|
|
400
|
+
Item creation in a managed collection is rejected. Native version migrations
|
|
401
|
+
and restores may preserve or extend local fields but cannot overwrite source
|
|
402
|
+
fields. Deleted Items still require restoration before projection resumes.
|
|
403
|
+
|
|
404
|
+
These contracts are immutable once a collection exists. An existing read-only
|
|
405
|
+
projection needs an explicit contract/definition migration before enabling edits;
|
|
406
|
+
changing only the App code does not silently unlock it.
|
|
407
|
+
|
|
408
|
+
Collections inherit the App's attachment audience at creation; subsequent native
|
|
409
|
+
ACL configuration stays authoritative for Item/Calendar readers. Each scope has
|
|
410
|
+
its own collection, but App-local roles are not automatically mapped to NightFUEL
|
|
411
|
+
roles. Configure native per-scope audiences before sharing data that requires
|
|
412
|
+
different visibility. A projection grants no access to the App's full record.
|
|
413
|
+
Requests are bounded to 32 fields, 64 KiB and 100 availability intervals.
|
|
414
|
+
|
|
415
|
+
## Native MCP identity (0.6)
|
|
416
|
+
|
|
417
|
+
Native App MCP transport signs each protocol request in `X-NF-App-Mcp`.
|
|
418
|
+
Use `verifyNightfuelMcpCall({ headers, method, path, rawBody, appId, secret })`
|
|
419
|
+
before looking up `claims.mcpConnectionId` in the App's explicit role bindings.
|
|
420
|
+
The verifier binds the exact bytes, path, method, App, project, connection and
|
|
421
|
+
expiry. Its signing purpose differs from generic `X-NF-App-Call`; a Call App
|
|
422
|
+
workflow cannot impersonate a native MCP connection. Cloud Run transport IAM
|
|
423
|
+
remains separate. Discovery carries the draft connection ID; execution uses the
|
|
424
|
+
published connection ID and rechecks current NF authority on each request.
|
|
425
|
+
Reject unbound, expired or revoked connections. Never grant admin by default.
|
|
426
|
+
|
|
427
|
+
## Native map projections (0.6)
|
|
428
|
+
|
|
429
|
+
`sdk.maps.sync({ scope, revision, title, pins, areas })` creates one native Map
|
|
430
|
+
per App and scope. Pins contain `{ id, title, position: [longitude, latitude] }`;
|
|
431
|
+
areas contain `{ id, title, ring, effect: 'allow' | 'deny' }`. Send a complete
|
|
432
|
+
snapshot, increasing `revision` whenever its contents change. Retry the same
|
|
433
|
+
revision and bytes after an uncertain response. The receipt contains `mapId`,
|
|
434
|
+
`scope`, `revision`, `mapVersion` and `status` (created, updated or replay).
|
|
435
|
+
|
|
436
|
+
Map, entities, version, binding and invalidation event commit together. Stale
|
|
437
|
+
revisions, changed payloads at the same revision, deleted maps and native edits
|
|
438
|
+
fail explicitly. The API cannot target arbitrary map IDs. Empty snapshots clear
|
|
439
|
+
drawings. Limits: 2,000 drawings, 200 vertices per area and 512 KiB per request.
|
|
440
|
+
Only send data appropriate for the App's NF project audience: App-local roles
|
|
441
|
+
do not become native ACLs. Map attachments follow the App; the next sync applies
|
|
442
|
+
attachment changes. A native attachment/content edit triggers a conflict.
|
|
443
|
+
|
|
444
|
+
Before enabling this adapter on a platform installation, review and apply
|
|
445
|
+
`node scripts/prepare_maps_indexes.js --apply` using the normal deployment
|
|
446
|
+
procedure. Runtime checks the unique bindings; it never silently repairs missing
|
|
447
|
+
production indexes. No deployment is performed by installing the SDK package.
|
package/events.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
declare const valueType: unique symbol;
|
|
2
|
+
export interface EventSchema<T = unknown> {
|
|
3
|
+
readonly type: string;
|
|
4
|
+
readonly [valueType]?: T;
|
|
5
|
+
}
|
|
6
|
+
export type InferEventSchema<S> = S extends EventSchema<infer T> ? T : never;
|
|
7
|
+
type OptionalKeys<P> = { [K in keyof P]: P[K] extends { readonly optional: true } ? K : never }[keyof P];
|
|
8
|
+
type ObjectPayload<P> = {
|
|
9
|
+
[K in Exclude<keyof P, OptionalKeys<P>>]: InferEventSchema<P[K]>
|
|
10
|
+
} & { [K in OptionalKeys<P>]?: InferEventSchema<P[K]> };
|
|
11
|
+
export const eventSchema: {
|
|
12
|
+
string(options?: { description?: string; minLength?: number; maxLength?: number; format?: 'date' | 'date-time' | 'uuid' }): EventSchema<string>;
|
|
13
|
+
number(options?: { description?: string; minimum?: number; maximum?: number }): EventSchema<number>;
|
|
14
|
+
integer(options?: { description?: string; minimum?: number; maximum?: number }): EventSchema<number>;
|
|
15
|
+
boolean(): EventSchema<boolean>;
|
|
16
|
+
enum<const T extends readonly [string, ...string[]]>(...values: T): EventSchema<T[number]>;
|
|
17
|
+
enum<const T extends readonly [number, ...number[]]>(...values: T): EventSchema<T[number]>;
|
|
18
|
+
enum<const T extends readonly [boolean, ...boolean[]]>(...values: T): EventSchema<T[number]>;
|
|
19
|
+
enum(value: null): EventSchema<null>;
|
|
20
|
+
array<S extends EventSchema>(items: S, options?: { minItems?: number; maxItems?: number }): EventSchema<InferEventSchema<S>[]>;
|
|
21
|
+
optional<S extends EventSchema>(schema: S): S & { readonly optional: true };
|
|
22
|
+
object<const P extends Record<string, EventSchema>>(properties: P): EventSchema<ObjectPayload<P>>;
|
|
23
|
+
};
|
|
24
|
+
export interface AppEventDefinition {
|
|
25
|
+
version: number;
|
|
26
|
+
title?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
payload: EventSchema;
|
|
29
|
+
}
|
|
30
|
+
export type AppEventDefinitions = Record<string, AppEventDefinition>;
|
|
31
|
+
export interface AppEventDescriptor {
|
|
32
|
+
key: string;
|
|
33
|
+
version: number;
|
|
34
|
+
title: string;
|
|
35
|
+
description: string;
|
|
36
|
+
payloadSchema: Record<string, unknown>;
|
|
37
|
+
contractHash: string;
|
|
38
|
+
}
|
|
39
|
+
export interface AppEventManifest {
|
|
40
|
+
schemaVersion: 'nightfuel.app-events.v1';
|
|
41
|
+
events: AppEventDescriptor[];
|
|
42
|
+
}
|
|
43
|
+
declare const definitionsType: unique symbol;
|
|
44
|
+
export interface DefinedAppEvents<T extends AppEventDefinitions = AppEventDefinitions> {
|
|
45
|
+
readonly manifest: AppEventManifest;
|
|
46
|
+
readonly [definitionsType]?: T;
|
|
47
|
+
}
|
|
48
|
+
export type EventPayload<C, K extends string> = C extends DefinedAppEvents<infer D>
|
|
49
|
+
? K extends keyof D ? InferEventSchema<D[K]['payload']> : never : never;
|
|
50
|
+
export function defineAppEvents<const T extends AppEventDefinitions>(definitions: T): DefinedAppEvents<T>;
|
|
51
|
+
export interface AppEventReceipt {
|
|
52
|
+
eventId: string;
|
|
53
|
+
status: 'accepted';
|
|
54
|
+
replay: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface AppEventClient<T extends AppEventDefinitions> {
|
|
57
|
+
emit<K extends keyof T & string>(key: K, payload: InferEventSchema<T[K]['payload']>,
|
|
58
|
+
options: { idempotencyKey: string; occurredAt: string }): Promise<AppEventReceipt>;
|
|
59
|
+
}
|
package/events.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared by the SDK and the platform. A deliberately bounded JSON Schema
|
|
4
|
+
// dialect keeps validation and TypeScript inference in agreement.
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const MANIFEST_PATH = '.nightfuel/events.json';
|
|
7
|
+
const SCHEMA_VERSION = 'nightfuel.app-events.v1';
|
|
8
|
+
const MAX_BYTES = 256 * 1024;
|
|
9
|
+
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
|
|
10
|
+
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
11
|
+
const plain = value => value && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
13
|
+
const fail = (message, code = 'APP_EVENT_CONTRACT_INVALID') => {
|
|
14
|
+
throw Object.assign(new Error(message), { code, status: 400, statusCode: 400 });
|
|
15
|
+
};
|
|
16
|
+
function exact(value, keys, at) {
|
|
17
|
+
if (!plain(value)) fail(`${at} must be an object`);
|
|
18
|
+
for (const key of Object.keys(value)) if (FORBIDDEN.has(key) || !keys.includes(key)) fail(`${at}.${key} is unsupported`);
|
|
19
|
+
}
|
|
20
|
+
function canonical(value) {
|
|
21
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
22
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
23
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
24
|
+
}
|
|
25
|
+
const digest = value => crypto.createHash('sha256').update(canonical(value)).digest('hex');
|
|
26
|
+
function text(value, at, max, required = false) {
|
|
27
|
+
if (value === undefined && !required) return '';
|
|
28
|
+
if (typeof value !== 'string' || value.length > max || (required && !value.trim())) fail(`${at} must be text of at most ${max} characters`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function normalizeSchema(value, depth = 0, budget = { nodes: 0 }) {
|
|
32
|
+
if (depth > 12 || ++budget.nodes > 1000) fail('Event schema is too complex');
|
|
33
|
+
exact(value, ['type', 'properties', 'required', 'additionalProperties', 'items', 'enum',
|
|
34
|
+
'description', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', 'format'], 'schema');
|
|
35
|
+
const schema = { type: value.type };
|
|
36
|
+
if (!['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(schema.type)) fail('Unsupported event field type');
|
|
37
|
+
if (value.description !== undefined) schema.description = text(value.description, 'description', 2000);
|
|
38
|
+
const allowed = new Set(['type', 'description']);
|
|
39
|
+
if (schema.type === 'object') {
|
|
40
|
+
['properties', 'required', 'additionalProperties'].forEach(key => allowed.add(key));
|
|
41
|
+
if (!plain(value.properties) || value.additionalProperties !== false) fail('Event objects require properties and additionalProperties:false');
|
|
42
|
+
const required = value.required ?? [];
|
|
43
|
+
if (!Array.isArray(required) || new Set(required).size !== required.length
|
|
44
|
+
|| required.some(key => typeof key !== 'string' || !own(value.properties, key))) fail('Invalid required event fields');
|
|
45
|
+
schema.properties = {};
|
|
46
|
+
for (const key of Object.keys(value.properties).sort()) {
|
|
47
|
+
if (FORBIDDEN.has(key) || !/^[A-Za-z_][A-Za-z0-9_]{0,79}$/.test(key)) fail(`Invalid event field: ${key}`);
|
|
48
|
+
schema.properties[key] = normalizeSchema(value.properties[key], depth + 1, budget);
|
|
49
|
+
}
|
|
50
|
+
schema.required = [...required].sort();
|
|
51
|
+
schema.additionalProperties = false;
|
|
52
|
+
} else if (schema.type === 'array') {
|
|
53
|
+
['items', 'minItems', 'maxItems'].forEach(key => allowed.add(key));
|
|
54
|
+
schema.items = normalizeSchema(value.items, depth + 1, budget);
|
|
55
|
+
} else {
|
|
56
|
+
allowed.add('enum');
|
|
57
|
+
if (value.enum !== undefined) {
|
|
58
|
+
if (!Array.isArray(value.enum) || !value.enum.length || value.enum.length > 100
|
|
59
|
+
|| value.enum.some(item => !scalarMatches(schema.type, item))
|
|
60
|
+
|| new Set(value.enum.map(canonical)).size !== value.enum.length) fail('Invalid event enum');
|
|
61
|
+
schema.enum = [...value.enum];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const bounds = schema.type === 'string' ? ['minLength', 'maxLength']
|
|
65
|
+
: schema.type === 'array' ? ['minItems', 'maxItems']
|
|
66
|
+
: ['number', 'integer'].includes(schema.type) ? ['minimum', 'maximum'] : [];
|
|
67
|
+
for (const key of bounds) {
|
|
68
|
+
allowed.add(key);
|
|
69
|
+
if (value[key] === undefined) continue;
|
|
70
|
+
if (!Number.isFinite(value[key]) || (!['minimum', 'maximum'].includes(key) && (!Number.isInteger(value[key]) || value[key] < 0))) fail(`Invalid ${key}`);
|
|
71
|
+
schema[key] = value[key];
|
|
72
|
+
}
|
|
73
|
+
if (bounds.length && schema[bounds[0]] !== undefined && schema[bounds[1]] !== undefined
|
|
74
|
+
&& schema[bounds[0]] > schema[bounds[1]]) fail('Event field bounds are reversed');
|
|
75
|
+
if (schema.type === 'string') {
|
|
76
|
+
allowed.add('format');
|
|
77
|
+
if (value.format !== undefined) {
|
|
78
|
+
if (!['date', 'date-time', 'uuid'].includes(value.format)) fail('Unsupported event string format');
|
|
79
|
+
schema.format = value.format;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) fail(`${key} is not valid for ${schema.type}`);
|
|
83
|
+
return schema;
|
|
84
|
+
}
|
|
85
|
+
function scalarMatches(type, value) {
|
|
86
|
+
if (type === 'null') return value === null;
|
|
87
|
+
if (type === 'integer') return Number.isSafeInteger(value);
|
|
88
|
+
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
|
89
|
+
return typeof value === type;
|
|
90
|
+
}
|
|
91
|
+
function validFormat(format, value) {
|
|
92
|
+
if (format === 'uuid') return /^[a-f\d]{8}-[a-f\d]{4}-[1-8][a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i.test(value);
|
|
93
|
+
if (format === 'date') return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value))
|
|
94
|
+
&& new Date(value).toISOString().slice(0, 10) === value;
|
|
95
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
|
|
96
|
+
&& validFormat('date', value.slice(0, 10)) && Number.isFinite(Date.parse(value));
|
|
97
|
+
}
|
|
98
|
+
function validatePayload(schema, value, at = 'payload') {
|
|
99
|
+
const reject = message => fail(`${at}: ${message}`, 'APP_EVENT_PAYLOAD_INVALID');
|
|
100
|
+
if (schema.type === 'object') {
|
|
101
|
+
if (!plain(value)) reject('expected object');
|
|
102
|
+
for (const key of schema.required) if (!own(value, key) || value[key] === undefined) reject(`missing ${key}`);
|
|
103
|
+
for (const key of Object.keys(value)) {
|
|
104
|
+
if (!own(schema.properties, key)) reject(`unknown field ${key}`);
|
|
105
|
+
// Optional TypeScript properties may be explicitly undefined; JSON
|
|
106
|
+
// omits them on the wire. Required properties were checked above.
|
|
107
|
+
if (value[key] === undefined) continue;
|
|
108
|
+
validatePayload(schema.properties[key], value[key], `${at}.${key}`);
|
|
109
|
+
}
|
|
110
|
+
} else if (schema.type === 'array') {
|
|
111
|
+
if (!Array.isArray(value)) reject('expected array');
|
|
112
|
+
if (value.length > (schema.maxItems ?? 10000) || value.length < (schema.minItems ?? 0)) reject('array length outside contract');
|
|
113
|
+
value.forEach((entry, index) => validatePayload(schema.items, entry, `${at}[${index}]`));
|
|
114
|
+
} else {
|
|
115
|
+
if (!scalarMatches(schema.type, value)) reject(`expected ${schema.type}`);
|
|
116
|
+
if (schema.enum && !schema.enum.some(entry => entry === value)) reject('value outside enum');
|
|
117
|
+
if (schema.type === 'string') {
|
|
118
|
+
if (value.length < (schema.minLength ?? 0) || value.length > (schema.maxLength ?? MAX_BYTES)) reject('text length outside contract');
|
|
119
|
+
if (schema.format && !validFormat(schema.format, value)) reject(`expected ${schema.format}`);
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === 'number' && (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity))) reject('number outside contract');
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function normalizeManifest(value) {
|
|
126
|
+
exact(value, ['schemaVersion', 'events'], 'manifest');
|
|
127
|
+
if (value.schemaVersion !== SCHEMA_VERSION || !Array.isArray(value.events) || value.events.length > 100) fail('Invalid app event manifest');
|
|
128
|
+
if (Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_BYTES) fail('App event manifest exceeds 256 KiB');
|
|
129
|
+
const keys = new Set();
|
|
130
|
+
const events = value.events.map(event => {
|
|
131
|
+
exact(event, ['key', 'version', 'title', 'description', 'payloadSchema', 'contractHash'], 'event');
|
|
132
|
+
if (typeof event.key !== 'string' || !/^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*){1,7}$/.test(event.key)
|
|
133
|
+
|| event.key.length > 120 || keys.has(event.key)) fail('Event keys must be unique dotted paths');
|
|
134
|
+
keys.add(event.key);
|
|
135
|
+
if (!Number.isSafeInteger(event.version) || event.version < 1) fail('Event version must be a positive integer');
|
|
136
|
+
const payloadSchema = normalizeSchema(event.payloadSchema);
|
|
137
|
+
if (payloadSchema.type !== 'object') fail('An event payload must be an object');
|
|
138
|
+
const normalized = {
|
|
139
|
+
key: event.key, version: event.version,
|
|
140
|
+
title: text(event.title, 'event.title', 160) || event.key,
|
|
141
|
+
description: text(event.description, 'event.description', 2000), payloadSchema,
|
|
142
|
+
};
|
|
143
|
+
const contractHash = digest({ key: event.key, version: event.version, payloadSchema });
|
|
144
|
+
if (event.contractHash !== undefined && event.contractHash !== contractHash) fail('Event contract hash does not match its schema');
|
|
145
|
+
return { ...normalized, contractHash };
|
|
146
|
+
}).sort((a, b) => a.key.localeCompare(b.key));
|
|
147
|
+
return { schemaVersion: SCHEMA_VERSION, events };
|
|
148
|
+
}
|
|
149
|
+
function freeze(value) {
|
|
150
|
+
if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value); }
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
function defineAppEvents(definitions) {
|
|
154
|
+
if (!plain(definitions)) fail('Event definitions must be an object');
|
|
155
|
+
return freeze({ manifest: normalizeManifest({ schemaVersion: SCHEMA_VERSION,
|
|
156
|
+
events: Object.entries(definitions).map(([key, definition]) => {
|
|
157
|
+
exact(definition, ['version', 'title', 'description', 'payload'], `events.${key}`);
|
|
158
|
+
return { key, version: definition.version, title: definition.title, description: definition.description, payloadSchema: definition.payload };
|
|
159
|
+
}) }) });
|
|
160
|
+
}
|
|
161
|
+
const eventSchema = Object.freeze({
|
|
162
|
+
string: (options = {}) => ({ type: 'string', ...options }),
|
|
163
|
+
number: (options = {}) => ({ type: 'number', ...options }),
|
|
164
|
+
integer: (options = {}) => ({ type: 'integer', ...options }),
|
|
165
|
+
boolean: () => ({ type: 'boolean' }),
|
|
166
|
+
enum: (...values) => ({ type: values[0] === null ? 'null' : typeof values[0], enum: values }),
|
|
167
|
+
array: (items, options = {}) => ({ type: 'array', items, ...options }),
|
|
168
|
+
optional: schema => ({ ...schema, optional: true }),
|
|
169
|
+
object: properties => ({
|
|
170
|
+
type: 'object', additionalProperties: false,
|
|
171
|
+
required: Object.keys(properties).filter(key => properties[key].optional !== true),
|
|
172
|
+
properties: Object.fromEntries(Object.entries(properties).map(([key, value]) => {
|
|
173
|
+
const { optional, ...schema } = value;
|
|
174
|
+
return [key, schema];
|
|
175
|
+
})),
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
function sampleFromSchema(schema) {
|
|
179
|
+
if (schema.enum) return schema.enum[0];
|
|
180
|
+
if (schema.type === 'object') return Object.fromEntries(Object.entries(schema.properties).map(([key, child]) => [key, sampleFromSchema(child)]));
|
|
181
|
+
if (schema.type === 'array') return [];
|
|
182
|
+
if (schema.type === 'boolean') return false;
|
|
183
|
+
if (['integer', 'number'].includes(schema.type)) return schema.minimum ?? 0;
|
|
184
|
+
if (schema.type === 'null') return null;
|
|
185
|
+
return '';
|
|
186
|
+
}
|
|
187
|
+
module.exports = { MANIFEST_PATH, SCHEMA_VERSION, MAX_BYTES, defineAppEvents, eventSchema,
|
|
188
|
+
normalizeManifest, normalizeSchema, validatePayload, sampleFromSchema, canonical, digest };
|
package/index.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import type { AppEventClient, AppEventDefinitions, DefinedAppEvents } from './events';
|
|
2
|
+
import type { AppItemsClient } from './items';
|
|
3
|
+
export { defineItemProjection, createItemWriteHandler } from './items';
|
|
4
|
+
export type { ItemProjectionDefinition, ItemProjectionField, ItemProjectionValues, ItemSyncInput, ItemSyncReceipt, ItemJson, ItemWriteInput } from './items';
|
|
5
|
+
export { defineAppEvents, eventSchema } from './events';
|
|
6
|
+
export type { AppEventClient, AppEventDefinitions, AppEventManifest, AppEventReceipt, DefinedAppEvents, EventPayload, InferEventSchema } from './events';
|
|
7
|
+
|
|
1
8
|
export interface NightfuelAppSdkErrorOptions {
|
|
2
9
|
status?: number;
|
|
3
10
|
code?: string;
|
|
@@ -180,14 +187,57 @@ export interface NightfuelWorkflowClient {
|
|
|
180
187
|
): AsyncGenerator<NightfuelWorkflowEvent<T>, void, undefined>;
|
|
181
188
|
}
|
|
182
189
|
|
|
183
|
-
export interface NightfuelAppSdk {
|
|
190
|
+
export interface NightfuelAppSdk<T extends AppEventDefinitions = Record<string, never>> {
|
|
191
|
+
readonly maps: { sync(input: AppMapSync): Promise<AppMapReceipt> };
|
|
184
192
|
readonly appId: string;
|
|
193
|
+
readonly items: AppItemsClient;
|
|
194
|
+
/** Server-only credentials owned by this exact App; no access to other project keys. */
|
|
195
|
+
readonly credentials: {
|
|
196
|
+
store(input: AppCredentialReference & { value: string }): Promise<{ configured: true; reference: AppCredentialReference; vaultKeyId: string }>;
|
|
197
|
+
read(input: AppCredentialReference): Promise<{ value: string }>;
|
|
198
|
+
remove(input: AppCredentialReference): Promise<{ configured: false; reference: AppCredentialReference }>;
|
|
199
|
+
};
|
|
200
|
+
readonly calendar: {
|
|
201
|
+
connect(input: { id: string; scope: string; resourceId: string; name: string; url: string }): Promise<AppCalendarSource>;
|
|
202
|
+
snapshot(input: { id: string; scope: string; refresh?: boolean }): Promise<{ source: AppCalendarSource; blocks: AppCalendarBlock[]; revision: number }>;
|
|
203
|
+
remove(input: { id: string; scope: string }): Promise<{ removed: true }>;
|
|
204
|
+
upsert(input: { id: string; scope: string; resourceId: string; from: string; to: string; title: string; version: number; active: boolean }): Promise<{ saved: true; version: number }>;
|
|
205
|
+
};
|
|
206
|
+
/** Durable platform callbacks that wake a scaled-to-zero App using its signed call identity. */
|
|
207
|
+
readonly jobs: {
|
|
208
|
+
ensure(input: { key: string; path: string; everySeconds: number }): Promise<{ key: string; enabled: boolean; everySeconds: number }>;
|
|
209
|
+
};
|
|
185
210
|
readonly data: NightfuelDataClient;
|
|
186
211
|
readonly workflows: NightfuelWorkflowClient;
|
|
212
|
+
readonly events: AppEventClient<T>;
|
|
187
213
|
createIdempotencyKey(): string;
|
|
188
214
|
}
|
|
215
|
+
export interface AppCredentialReference { namespace: string; scope: string; name: string }
|
|
216
|
+
export interface AppMapSync {
|
|
217
|
+
scope: string; revision: number; title: string;
|
|
218
|
+
pins: Array<{ id: string; title: string; position: [number, number] }>;
|
|
219
|
+
areas: Array<{ id: string; title: string; ring: Array<[number, number]>; effect: 'allow' | 'deny' }>;
|
|
220
|
+
}
|
|
221
|
+
export interface AppMapReceipt {
|
|
222
|
+
mapId: string; scope: string; revision: number; mapVersion: number;
|
|
223
|
+
status: 'created' | 'updated' | 'replay';
|
|
224
|
+
}
|
|
225
|
+
export interface NightfuelMcpClaims extends NightfuelCallClaims { purpose: 'native_mcp'; mcpConnectionId: string }
|
|
226
|
+
/** Only the native MCP transport can issue this purpose-separated proof. Bind its connection to local roles. */
|
|
227
|
+
export function verifyNightfuelMcpCall(input: {
|
|
228
|
+
headers: Headers | Record<string, string | string[] | undefined>;
|
|
229
|
+
method: string; path: string; rawBody: string | Buffer;
|
|
230
|
+
secret?: string; appId?: string; projectId?: string; now?: number;
|
|
231
|
+
}): NightfuelMcpClaims;
|
|
232
|
+
export interface AppCalendarBlock { id: string; from: string; to: string }
|
|
233
|
+
export interface AppCalendarSource {
|
|
234
|
+
id: string; scope: string; resourceId: string; name: string; enabled: boolean;
|
|
235
|
+
status: 'pending' | 'ok' | 'error'; lastSyncedAt: string | null; error: string | null; exportUrl: string | null;
|
|
236
|
+
}
|
|
189
237
|
|
|
190
|
-
export function createNightfuelAppSdk
|
|
238
|
+
export function createNightfuelAppSdk<const T extends AppEventDefinitions = Record<string, never>>(
|
|
239
|
+
options?: NightfuelAppSdkOptions & { events?: DefinedAppEvents<T> }
|
|
240
|
+
): NightfuelAppSdk<T>;
|
|
191
241
|
|
|
192
242
|
/** The claims Nightfuel signs into an Apphook request. */
|
|
193
243
|
export interface NightfuelCallClaims {
|
|
@@ -225,3 +275,5 @@ export function verifyNightfuelCall(input: {
|
|
|
225
275
|
appId?: string;
|
|
226
276
|
now?: number;
|
|
227
277
|
}): NightfuelCallClaims;
|
|
278
|
+
export { defineAppResources, resourceSchema, createAppResourceHandler } from './resources';
|
|
279
|
+
export type { AppResourceDefinition, AppResourceDescriptor, AppResourceManifest, DefinedAppResources, AppResourcePrincipal, AppResourceReadContext, AppResourceReply } from './resources';
|
package/index.js
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
const crypto = require('crypto');
|
|
10
10
|
const { parseSseFrame, recoverableSseStream, terminalRunStatus } = require('./stream');
|
|
11
|
+
const AppEvents = require('./events');
|
|
12
|
+
const { defineAppEvents, eventSchema } = AppEvents;
|
|
13
|
+
const { defineAppResources, resourceSchema, createAppResourceHandler } = require('./resources');
|
|
14
|
+
const { defineItemProjection, validateItemSync } = require('./items');
|
|
15
|
+
const { createItemWriteHandler } = require('./item-writes');
|
|
16
|
+
const { verifyNightfuelMcpCall } = require('./mcp');
|
|
11
17
|
|
|
12
18
|
const METADATA_IDENTITY_URL =
|
|
13
19
|
'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity';
|
|
@@ -113,6 +119,7 @@ async function responsePayload(response) {
|
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
function createNightfuelAppSdk(options = {}) {
|
|
122
|
+
const eventManifest = options.events ? AppEvents.normalizeManifest(options.events.manifest) : null;
|
|
116
123
|
requiredServerRuntime();
|
|
117
124
|
const env = options.env || process.env;
|
|
118
125
|
const fetchImpl = options.fetch || globalThis.fetch;
|
|
@@ -175,17 +182,39 @@ function createNightfuelAppSdk(options = {}) {
|
|
|
175
182
|
return response;
|
|
176
183
|
}
|
|
177
184
|
|
|
178
|
-
async function postJson(path, body, headers = {}) {
|
|
185
|
+
async function postJson(path, body, headers = {}, signal) {
|
|
179
186
|
const response = await request(path, {
|
|
180
187
|
method: 'POST',
|
|
181
188
|
headers: { 'Content-Type': 'application/json', ...headers },
|
|
182
189
|
body: JSON.stringify(body),
|
|
190
|
+
signal,
|
|
183
191
|
});
|
|
184
192
|
return responsePayload(response);
|
|
185
193
|
}
|
|
194
|
+
const integrationRequest = (path, input) => postJson(`/internal/hosting/apps/${appId}/${path}`, input, {}, AbortSignal.timeout(15000));
|
|
186
195
|
|
|
187
196
|
return Object.freeze({
|
|
197
|
+
maps: Object.freeze({ sync: input => integrationRequest('maps/sync', require('./maps').validateMapSync(input)) }),
|
|
188
198
|
appId,
|
|
199
|
+
items: Object.freeze({
|
|
200
|
+
async sync(definition, input) {
|
|
201
|
+
return integrationRequest('items/sync', validateItemSync(definition, input));
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
credentials: Object.freeze({
|
|
205
|
+
store: input => integrationRequest('credentials/store', input),
|
|
206
|
+
read: input => integrationRequest('credentials/read', input),
|
|
207
|
+
remove: input => integrationRequest('credentials/remove', input),
|
|
208
|
+
}),
|
|
209
|
+
calendar: Object.freeze({
|
|
210
|
+
connect: input => integrationRequest('calendar/connect', input),
|
|
211
|
+
snapshot: input => integrationRequest('calendar/snapshot', input),
|
|
212
|
+
remove: input => integrationRequest('calendar/remove', input),
|
|
213
|
+
upsert: input => integrationRequest('calendar/upsert', input),
|
|
214
|
+
}),
|
|
215
|
+
jobs: Object.freeze({
|
|
216
|
+
ensure: input => integrationRequest('jobs/ensure', input),
|
|
217
|
+
}),
|
|
189
218
|
data: Object.freeze({
|
|
190
219
|
query: (input) => postJson(`/internal/hosting/apps/${appId}/data/query`, input || {}),
|
|
191
220
|
semantic: (input) => postJson(`/internal/hosting/apps/${appId}/data/semantic`, input || {}),
|
|
@@ -201,6 +230,23 @@ function createNightfuelAppSdk(options = {}) {
|
|
|
201
230
|
);
|
|
202
231
|
},
|
|
203
232
|
}),
|
|
233
|
+
events: Object.freeze({
|
|
234
|
+
async emit(eventKey, payload, options = {}) {
|
|
235
|
+
const definition = eventManifest?.events.find(event => event.key === eventKey);
|
|
236
|
+
if (!definition) throw new NightfuelAppSdkError('Declare this event with defineAppEvents before emitting it', {
|
|
237
|
+
code: 'APP_EVENT_UNDECLARED',
|
|
238
|
+
});
|
|
239
|
+
const key = idempotencyKey(options.idempotencyKey);
|
|
240
|
+
AppEvents.validatePayload(definition.payloadSchema, payload);
|
|
241
|
+
AppEvents.validatePayload({ type: 'string', format: 'date-time' }, options.occurredAt, 'occurredAt');
|
|
242
|
+
const body = { eventKey, version: definition.version, contractHash: definition.contractHash,
|
|
243
|
+
occurredAt: options.occurredAt, payload };
|
|
244
|
+
if (Buffer.byteLength(JSON.stringify(body), 'utf8') > AppEvents.MAX_BYTES) {
|
|
245
|
+
throw new NightfuelAppSdkError('App event exceeds 256 KiB', { code: 'APP_EVENT_TOO_LARGE', status: 413 });
|
|
246
|
+
}
|
|
247
|
+
return postJson(`/internal/hosting/apps/${appId}/events`, body, { 'Idempotency-Key': key }, AbortSignal.timeout(15000));
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
204
250
|
workflows: Object.freeze({
|
|
205
251
|
async run(rawBindingId, input = {}, options = {}) {
|
|
206
252
|
const resolvedBindingId = bindingId(rawBindingId);
|
|
@@ -324,6 +370,14 @@ function verifyNightfuelCall({ headers, method, path, rawBody, secret, appId, no
|
|
|
324
370
|
}
|
|
325
371
|
|
|
326
372
|
module.exports = {
|
|
373
|
+
verifyNightfuelMcpCall,
|
|
374
|
+
defineItemProjection,
|
|
375
|
+
createItemWriteHandler,
|
|
376
|
+
defineAppResources,
|
|
377
|
+
resourceSchema,
|
|
378
|
+
createAppResourceHandler,
|
|
379
|
+
defineAppEvents,
|
|
380
|
+
eventSchema,
|
|
327
381
|
NightfuelAppSdkError,
|
|
328
382
|
createNightfuelAppSdk,
|
|
329
383
|
terminalRunStatus,
|
package/item-writes.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const { defineItemProjection, validateItemSync } = require('./items');
|
|
4
|
+
const fail = (message, statusCode = 400, code = 'APP_ITEM_WRITE_INVALID') => Object.assign(new Error(message), { statusCode, status: statusCode, code });
|
|
5
|
+
function canonical(value) {
|
|
6
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
7
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
const digest = value => crypto.createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
|
|
11
|
+
function exact(value, allowed) {
|
|
12
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !allowed.includes(key))) throw fail('Invalid Item write object');
|
|
13
|
+
}
|
|
14
|
+
function itemWriteSecret(secret) {
|
|
15
|
+
if (typeof secret !== 'string' || secret.length < 32) throw fail('Item write secret unavailable', 503);
|
|
16
|
+
return crypto.createHmac('sha256', secret).update('nightfuel.app-item-write.v1').digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function validateItemWrite(definitionInput, input) {
|
|
19
|
+
const definition = defineItemProjection(definitionInput);
|
|
20
|
+
if (!definition.writePath) throw fail('This projection does not accept edits');
|
|
21
|
+
exact(input, ['appId', 'key', 'contractHash', 'scope', 'id', 'expectedVersion', 'mutationId', 'principal', 'changes']);
|
|
22
|
+
if (input.key !== definition.key || input.contractHash !== digest(definition)) throw fail('App Item contract changed', 409, 'APP_ITEM_CONTRACT_CONFLICT');
|
|
23
|
+
if (!/^[a-f\d]{24}$/i.test(input.appId || '') || !/^[a-zA-Z0-9_-]{8,80}$/.test(input.mutationId || '')) throw fail('Invalid Item write identity');
|
|
24
|
+
exact(input.principal, ['type', 'id', 'projectId', 'workspaceId']);
|
|
25
|
+
if (!['user', 'agent', 'workflow', 'mcp_connection'].includes(input.principal.type) ||
|
|
26
|
+
['id', 'projectId', 'workspaceId'].some(key => !/^[a-f\d]{24}$/i.test(input.principal[key] || ''))) throw fail('Invalid Item principal', 403);
|
|
27
|
+
exact(input.changes, ['fields', 'stageKey']);
|
|
28
|
+
const fields = input.changes.fields || {};
|
|
29
|
+
for (const key of Object.keys(fields)) if (!definition.fields[key]?.editable) throw fail(`Field ${key} is controlled by the App`);
|
|
30
|
+
const checked = validateItemSync(definition, { scope: input.scope, id: input.id, version: input.expectedVersion, title: 'Item edit', fields,
|
|
31
|
+
...(definition.stages ? { stageKey: input.changes.stageKey ?? definition.stages[0].key } : {}) });
|
|
32
|
+
if (input.changes.stageKey !== undefined && !definition.stages) throw fail('This projection has no stages');
|
|
33
|
+
if (!Object.keys(fields).length && input.changes.stageKey === undefined) throw fail('An Item edit needs a change');
|
|
34
|
+
return { ...input, changes: { fields: checked.fields, ...(input.changes.stageKey !== undefined ? { stageKey: checked.stageKey } : {}) } };
|
|
35
|
+
}
|
|
36
|
+
function validateItemWriteReply(definition, input, result) {
|
|
37
|
+
const checked = validateItemSync(definition, result);
|
|
38
|
+
if (checked.id !== input.id || checked.scope !== input.scope || checked.version <= input.expectedVersion) throw fail('App reply does not match the edited Item', 502, 'APP_ITEM_WRITE_REPLY_INVALID');
|
|
39
|
+
const { definition: _, ...reply } = checked;
|
|
40
|
+
return reply;
|
|
41
|
+
}
|
|
42
|
+
function createItemWriteHandler(definitionInput, { authorize, write, secret, appId } = {}) {
|
|
43
|
+
const definition = defineItemProjection(definitionInput);
|
|
44
|
+
if (!definition.writePath || typeof authorize !== 'function' || typeof write !== 'function') throw fail('Item write handler requires explicit authorization and a writer');
|
|
45
|
+
return async request => {
|
|
46
|
+
if (request?.method !== 'POST' || request.path !== definition.writePath || !(request.rawBody instanceof Uint8Array) || request.rawBody.length > 65536) throw fail('Invalid Item write request');
|
|
47
|
+
// Generic Apphook signatures cannot delegate a user's Item permissions.
|
|
48
|
+
const claims = require('./index').verifyNightfuelCall({ ...request, secret: itemWriteSecret(secret || process.env.NF_APP_CALL_SECRET), appId });
|
|
49
|
+
let raw;
|
|
50
|
+
try { raw = JSON.parse(Buffer.from(request.rawBody).toString('utf8')); } catch { throw fail('Invalid Item write JSON'); }
|
|
51
|
+
const input = validateItemWrite(definition, raw);
|
|
52
|
+
if (claims.appId !== input.appId || claims.projectId !== input.principal.projectId) throw fail('Item principal scope mismatch', 403);
|
|
53
|
+
if (await authorize(input) !== true) throw fail('Item edit denied by the App', 403, 'APP_ITEM_WRITE_DENIED');
|
|
54
|
+
return validateItemWriteReply(definition, input, await write(input));
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
module.exports = { createItemWriteHandler, validateItemWrite, validateItemWriteReply, itemWriteSecret, digest };
|
package/items.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type ItemJson = null | boolean | number | string | ItemJson[] | { [key: string]: ItemJson };
|
|
2
|
+
export interface ItemProjectionField {
|
|
3
|
+
type: 'string' | 'integer' | 'number' | 'boolean' | 'date' | 'datetime' | 'json';
|
|
4
|
+
label: string;
|
|
5
|
+
/** Native edits call the App's signed writer; never a local field overwrite. */
|
|
6
|
+
editable?: boolean;
|
|
7
|
+
calendar?: { endField: string };
|
|
8
|
+
}
|
|
9
|
+
export interface ItemProjectionDefinition {
|
|
10
|
+
key: string;
|
|
11
|
+
title: string;
|
|
12
|
+
fields: Readonly<Record<string, ItemProjectionField>>;
|
|
13
|
+
writePath?: string;
|
|
14
|
+
stages?: readonly { key: string; label: string; next: readonly string[] }[];
|
|
15
|
+
}
|
|
16
|
+
type FieldValue<F extends ItemProjectionField> = F['type'] extends 'json' ? ItemJson
|
|
17
|
+
: F['type'] extends 'integer' | 'number' ? number | null
|
|
18
|
+
: F['type'] extends 'boolean' ? boolean | null : string | null;
|
|
19
|
+
export type ItemProjectionValues<D extends ItemProjectionDefinition> = { [K in keyof D['fields']]?: FieldValue<D['fields'][K]> };
|
|
20
|
+
export interface ItemSyncInput<D extends ItemProjectionDefinition> {
|
|
21
|
+
scope: string;
|
|
22
|
+
id: string;
|
|
23
|
+
/** Monotonic source version. Retry with exactly the same payload and version. */
|
|
24
|
+
version: number;
|
|
25
|
+
title: string;
|
|
26
|
+
/** Omitted fields are preserved; null clears a field. */
|
|
27
|
+
fields: ItemProjectionValues<D>;
|
|
28
|
+
stageKey?: D extends { stages: readonly { key: infer K extends string }[] } ? K : string;
|
|
29
|
+
/** Optional full snapshot for iCal export; native Calendar renders the Item's date fields. */
|
|
30
|
+
availability?: {
|
|
31
|
+
events: { id: string; resourceId: string; from: string; to: string }[];
|
|
32
|
+
/** Adopt explicitly named legacy calendar.upsert events from this App/scope. */
|
|
33
|
+
replaceEventIds?: string[];
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export interface ItemSyncReceipt {
|
|
37
|
+
itemId: string;
|
|
38
|
+
pipelineId: string;
|
|
39
|
+
version: number;
|
|
40
|
+
status: 'created' | 'updated' | 'replay' | 'stale';
|
|
41
|
+
}
|
|
42
|
+
export interface AppItemsClient {
|
|
43
|
+
sync<const D extends ItemProjectionDefinition>(definition: D, input: ItemSyncInput<NoInfer<D>>): Promise<ItemSyncReceipt>;
|
|
44
|
+
}
|
|
45
|
+
export function defineItemProjection<const D extends ItemProjectionDefinition>(definition: D): D;
|
|
46
|
+
export interface ItemWriteInput<D extends ItemProjectionDefinition> {
|
|
47
|
+
appId: string;
|
|
48
|
+
key: D['key'];
|
|
49
|
+
contractHash: string;
|
|
50
|
+
scope: string;
|
|
51
|
+
id: string;
|
|
52
|
+
expectedVersion: number;
|
|
53
|
+
/** Persist a receipt atomically with the domain change; retries reuse this ID. */
|
|
54
|
+
mutationId: string;
|
|
55
|
+
principal: { type: 'user' | 'agent' | 'workflow' | 'mcp_connection'; id: string; projectId: string; workspaceId: string };
|
|
56
|
+
changes: {
|
|
57
|
+
fields: { [K in keyof D['fields'] as D['fields'][K] extends { editable: true } ? K : never]?: FieldValue<D['fields'][K]> };
|
|
58
|
+
stageKey?: ItemSyncInput<D>['stageKey'];
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function createItemWriteHandler<const D extends ItemProjectionDefinition>(definition: D, options: {
|
|
62
|
+
authorize(input: ItemWriteInput<NoInfer<D>>): boolean | Promise<boolean>;
|
|
63
|
+
write(input: ItemWriteInput<NoInfer<D>>): Promise<ItemSyncInput<NoInfer<D>>>;
|
|
64
|
+
secret?: string;
|
|
65
|
+
appId?: string;
|
|
66
|
+
}): (request: { method: string; path: string; rawBody: Uint8Array; headers: Record<string, string | string[] | undefined> }) => Promise<ItemSyncInput<D>>;
|
package/items.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const TYPES = new Set(['string', 'integer', 'number', 'boolean', 'date', 'datetime', 'json']);
|
|
4
|
+
const fail = message => Object.assign(new Error(message), { status: 400, statusCode: 400, code: 'APP_ITEM_INPUT_INVALID' });
|
|
5
|
+
function object(value, name) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw fail(`${name} must be an object`);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function keys(value, allowed, name) {
|
|
10
|
+
object(value, name);
|
|
11
|
+
if (Object.keys(value).some(key => !allowed.includes(key))) throw fail(`${name} contains an undeclared property`);
|
|
12
|
+
}
|
|
13
|
+
function part(value, name) {
|
|
14
|
+
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(value)) throw fail(`${name} is invalid`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function label(value, name) {
|
|
18
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 160) throw fail(`${name} is invalid`);
|
|
19
|
+
return value.trim();
|
|
20
|
+
}
|
|
21
|
+
function json(value, depth = 0) {
|
|
22
|
+
if (depth > 12) throw fail('JSON is too deeply nested');
|
|
23
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) return value;
|
|
24
|
+
if (Array.isArray(value)) return value.map(entry => json(entry, depth + 1));
|
|
25
|
+
object(value, 'JSON');
|
|
26
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, json(entry, depth + 1)]));
|
|
27
|
+
}
|
|
28
|
+
function date(value) {
|
|
29
|
+
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
30
|
+
}
|
|
31
|
+
function defineItemProjection(input) {
|
|
32
|
+
keys(input, ['key', 'title', 'fields', 'stages', 'writePath'], 'projection');
|
|
33
|
+
const key = part(input.key, 'projection.key');
|
|
34
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(key)) throw fail('projection.key must be a lowercase Item type key');
|
|
35
|
+
const title = label(input.title, 'projection.title');
|
|
36
|
+
object(input.fields, 'projection.fields');
|
|
37
|
+
const entries = Object.entries(input.fields);
|
|
38
|
+
if (!entries.length || entries.length > 32) throw fail('A projection needs 1 to 32 fields');
|
|
39
|
+
const fields = Object.fromEntries(entries.map(([key, field]) => {
|
|
40
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(key) || ['constructor', 'prototype', '__proto__'].includes(key)) throw fail('Field key is invalid');
|
|
41
|
+
keys(field, ['type', 'label', 'calendar', 'editable'], key);
|
|
42
|
+
if (!TYPES.has(field.type)) throw fail(`Unsupported type for ${key}`);
|
|
43
|
+
const result = { type: field.type, label: label(field.label, key) };
|
|
44
|
+
if (field.editable !== undefined) {
|
|
45
|
+
if (typeof field.editable !== 'boolean') throw fail('editable must be a boolean');
|
|
46
|
+
result.editable = field.editable;
|
|
47
|
+
}
|
|
48
|
+
if (field.calendar !== undefined) {
|
|
49
|
+
keys(field.calendar, ['endField'], `${key}.calendar`);
|
|
50
|
+
if (field.type !== 'datetime' || field.calendar.endField === key || input.fields[field.calendar.endField]?.type !== 'datetime') throw fail('Calendar intervals need two different datetime fields');
|
|
51
|
+
result.calendar = Object.freeze({ endField: field.calendar.endField });
|
|
52
|
+
}
|
|
53
|
+
return [key, Object.freeze(result)];
|
|
54
|
+
}));
|
|
55
|
+
const result = { key, title, fields: Object.freeze(fields) };
|
|
56
|
+
if (input.writePath !== undefined) {
|
|
57
|
+
if (typeof input.writePath !== 'string' || !/^\/api\/internal\/[a-zA-Z0-9/_-]{1,180}$/.test(input.writePath)) throw fail('writePath must be an internal API path');
|
|
58
|
+
result.writePath = input.writePath;
|
|
59
|
+
}
|
|
60
|
+
if (entries.some(([, field]) => field.editable) && !result.writePath) throw fail('Editable fields require writePath');
|
|
61
|
+
if (input.stages !== undefined) {
|
|
62
|
+
if (!Array.isArray(input.stages) || !input.stages.length || input.stages.length > 32) throw fail('stages needs 1 to 32 entries');
|
|
63
|
+
const stageKeys = new Set(input.stages.map(stage => stage.key));
|
|
64
|
+
if (stageKeys.size !== input.stages.length) throw fail('Duplicate stage key');
|
|
65
|
+
result.stages = Object.freeze(input.stages.map(stage => {
|
|
66
|
+
keys(stage, ['key', 'label', 'next'], 'stage');
|
|
67
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(stage.key)) throw fail('Invalid stage key');
|
|
68
|
+
if (!Array.isArray(stage.next) || stage.next.some(key => !stageKeys.has(key) || key === stage.key) || new Set(stage.next).size !== stage.next.length) throw fail('Invalid stage transitions');
|
|
69
|
+
return Object.freeze({ key: stage.key, label: label(stage.label, 'stage.label'), next: Object.freeze([...stage.next]) });
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
return Object.freeze(result);
|
|
73
|
+
}
|
|
74
|
+
function validateItemSync(definitionInput, input) {
|
|
75
|
+
const definition = defineItemProjection(definitionInput);
|
|
76
|
+
keys(input, ['scope', 'id', 'version', 'title', 'fields', 'availability', 'stageKey'], 'sync');
|
|
77
|
+
const result = { definition, scope: part(input.scope, 'scope'), id: part(input.id, 'id'), version: input.version, title: label(input.title, 'title') };
|
|
78
|
+
if (!Number.isSafeInteger(input.version) || input.version < 1) throw fail('version must be a positive safe integer');
|
|
79
|
+
if (definition.stages) {
|
|
80
|
+
if (!definition.stages.some(stage => stage.key === input.stageKey)) throw fail('stageKey must identify a declared stage');
|
|
81
|
+
result.stageKey = input.stageKey;
|
|
82
|
+
} else if (input.stageKey !== undefined) throw fail('This projection has no stages');
|
|
83
|
+
object(input.fields, 'fields');
|
|
84
|
+
result.fields = Object.fromEntries(Object.entries(input.fields).map(([key, value]) => {
|
|
85
|
+
const field = Object.prototype.hasOwnProperty.call(definition.fields, key) ? definition.fields[key] : null;
|
|
86
|
+
if (!field) throw fail(`Undeclared field: ${key}`);
|
|
87
|
+
const valid = value === null || (field.type === 'string' && typeof value === 'string' && value.length <= 8000)
|
|
88
|
+
|| (field.type === 'integer' && Number.isSafeInteger(value)) || (field.type === 'number' && Number.isFinite(value))
|
|
89
|
+
|| (field.type === 'boolean' && typeof value === 'boolean') || (field.type === 'date' && date(value))
|
|
90
|
+
|| (field.type === 'datetime' && typeof value === 'string' && date(value.slice(0, 10))
|
|
91
|
+
&& /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value) && Number.isFinite(Date.parse(value)))
|
|
92
|
+
|| field.type === 'json';
|
|
93
|
+
if (!valid) throw fail(`Invalid value for ${key}`);
|
|
94
|
+
return [key, json(value)];
|
|
95
|
+
}));
|
|
96
|
+
if (input.availability !== undefined) {
|
|
97
|
+
keys(input.availability, ['events', 'replaceEventIds'], 'availability');
|
|
98
|
+
if (!Array.isArray(input.availability.events) || input.availability.events.length > 100) throw fail('availability.events must have at most 100 intervals');
|
|
99
|
+
const events = input.availability.events.map(event => {
|
|
100
|
+
keys(event, ['id', 'resourceId', 'from', 'to'], 'availability event');
|
|
101
|
+
if (!date(event.from) || !date(event.to) || event.from >= event.to) throw fail('Availability dates must form a nonempty, exclusive-end interval');
|
|
102
|
+
return { id: part(event.id, 'event.id'), resourceId: part(event.resourceId, 'resourceId'), from: event.from, to: event.to };
|
|
103
|
+
});
|
|
104
|
+
if (new Set(events.map(event => event.id)).size !== events.length) throw fail('Duplicate availability event');
|
|
105
|
+
const replaceEventIds = input.availability.replaceEventIds ?? [];
|
|
106
|
+
if (!Array.isArray(replaceEventIds) || replaceEventIds.length > 100) throw fail('replaceEventIds is invalid');
|
|
107
|
+
result.availability = { events, replaceEventIds: replaceEventIds.map(value => part(value, 'replaceEventIds')) };
|
|
108
|
+
}
|
|
109
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 65536) throw fail('Item projection exceeds 64 KiB');
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
module.exports = { defineItemProjection, validateItemSync };
|
package/maps.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fail = message => Object.assign(new Error(message), { status: 400, statusCode: 400, code: 'APP_MAP_INPUT_INVALID' });
|
|
3
|
+
function object(value, keys, label) {
|
|
4
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) throw fail(`${label} has unsupported fields`);
|
|
5
|
+
}
|
|
6
|
+
function text(value, max, label) {
|
|
7
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max) throw fail(`${label} is invalid`);
|
|
8
|
+
return value.trim();
|
|
9
|
+
}
|
|
10
|
+
function key(value, label) {
|
|
11
|
+
if (typeof value !== 'string' || !/^[a-zA-Z0-9][\w-]{0,79}$/.test(value)) throw fail(`${label} is invalid`);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function point(value) {
|
|
15
|
+
if (!Array.isArray(value) || value.length !== 2 || !value.every(Number.isFinite) || Math.abs(value[0]) > 180 || Math.abs(value[1]) > 85) throw fail('Position must be [longitude, latitude]');
|
|
16
|
+
return [...value];
|
|
17
|
+
}
|
|
18
|
+
function validateMapSync(input) {
|
|
19
|
+
object(input, ['scope', 'revision', 'title', 'pins', 'areas'], 'Map projection');
|
|
20
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1) throw fail('revision must be a positive safe integer');
|
|
21
|
+
if (!Array.isArray(input.pins) || !Array.isArray(input.areas) || input.pins.length + input.areas.length > 2000) throw fail('A map supports at most 2000 drawings');
|
|
22
|
+
const pins = input.pins.map(p => {
|
|
23
|
+
object(p, ['id', 'title', 'position'], 'Pin');
|
|
24
|
+
return { id: key(p.id, 'Pin id'), title: text(p.title, 160, 'Pin title'), position: point(p.position) };
|
|
25
|
+
});
|
|
26
|
+
const areas = input.areas.map(a => {
|
|
27
|
+
object(a, ['id', 'title', 'ring', 'effect'], 'Area');
|
|
28
|
+
if (!Array.isArray(a.ring) || a.ring.length < 3 || a.ring.length > 200 || !['allow', 'deny'].includes(a.effect)) throw fail('Area geometry or effect is invalid');
|
|
29
|
+
return { id: key(a.id, 'Area id'), title: text(a.title, 160, 'Area title'), ring: a.ring.map(point), effect: a.effect };
|
|
30
|
+
});
|
|
31
|
+
if (new Set(pins.map(p => p.id)).size !== pins.length || new Set(areas.map(a => a.id)).size !== areas.length) throw fail('Drawing ids must be unique within each kind');
|
|
32
|
+
const result = { scope: key(input.scope, 'scope'), revision: input.revision, title: text(input.title, 160, 'title'), pins, areas };
|
|
33
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 524288) throw fail('Map projection exceeds 512 KiB');
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
module.exports = { validateMapSync };
|
package/mcp.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const MCP_CALL_HEADER = 'X-NF-App-Mcp';
|
|
4
|
+
const PURPOSE = 'native_mcp';
|
|
5
|
+
function mcpCallSecret(secret) {
|
|
6
|
+
if (!secret) throw Object.assign(new Error('NF_APP_CALL_SECRET is required'), { status: 503, code: 'NIGHTFUEL_APP_CALL_SECRET_MISSING' });
|
|
7
|
+
return crypto.createHmac('sha256', String(secret)).update('nightfuel-app-mcp:v1').digest('hex');
|
|
8
|
+
}
|
|
9
|
+
function verifyNightfuelMcpCall(input = {}) {
|
|
10
|
+
const { headers, method, path, rawBody, projectId, ...options } = input;
|
|
11
|
+
const appId = String(options.appId || process.env.NF_APP_ID || '');
|
|
12
|
+
const fail = () => Object.assign(new Error('Native MCP identity is invalid'), { status: 401, code: 'NIGHTFUEL_APP_MCP_TOKEN_INVALID' });
|
|
13
|
+
if (!/^[a-f0-9]{24}$/i.test(appId) || !method || !path || rawBody === undefined) throw fail();
|
|
14
|
+
const token = typeof headers?.get === 'function' ? headers.get(MCP_CALL_HEADER)
|
|
15
|
+
: headers?.[Object.keys(headers || {}).find(key => key.toLowerCase() === MCP_CALL_HEADER.toLowerCase())];
|
|
16
|
+
if (typeof token !== 'string') throw fail();
|
|
17
|
+
const claims = require('./index').verifyNightfuelCall({ ...options, appId, method, path, rawBody,
|
|
18
|
+
headers: { 'x-nf-app-call': token }, secret: mcpCallSecret(options.secret || process.env.NF_APP_CALL_SECRET) });
|
|
19
|
+
if (claims.purpose !== PURPOSE || !/^[a-f0-9]{24}$/.test(claims.mcpConnectionId || '')
|
|
20
|
+
|| !/^[a-f0-9]{24}$/.test(claims.projectId || '') || (projectId && projectId !== claims.projectId)
|
|
21
|
+
|| !Number.isFinite(claims.iat) || !Number.isFinite(claims.exp)
|
|
22
|
+
|| claims.exp <= claims.iat || claims.exp - claims.iat > 300000) throw fail();
|
|
23
|
+
return claims;
|
|
24
|
+
}
|
|
25
|
+
module.exports = { MCP_CALL_HEADER, PURPOSE, mcpCallSecret, verifyNightfuelMcpCall };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightfuel-ai/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Server-only Nightfuel SDK for a hosted App workload identity.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"index.js",
|
|
16
16
|
"index.d.ts",
|
|
17
|
+
"maps.js",
|
|
18
|
+
"mcp.js",
|
|
19
|
+
"items.js",
|
|
20
|
+
"item-writes.js",
|
|
21
|
+
"items.d.ts",
|
|
22
|
+
"events.js",
|
|
23
|
+
"events.d.ts",
|
|
24
|
+
"resources.js",
|
|
25
|
+
"resources.d.ts",
|
|
17
26
|
"stream.js",
|
|
18
27
|
"README.md",
|
|
19
28
|
"LICENSE"
|
package/resources.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { EventSchema, InferEventSchema } from './events';
|
|
2
|
+
export { eventSchema as resourceSchema } from './events';
|
|
3
|
+
export interface AppResourceDefinition {
|
|
4
|
+
key: string;
|
|
5
|
+
version: number;
|
|
6
|
+
title?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
item: EventSchema<{ id: string }>;
|
|
9
|
+
}
|
|
10
|
+
export interface AppResourceDescriptor {
|
|
11
|
+
key: string;
|
|
12
|
+
version: number;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
itemSchema: Record<string, unknown>;
|
|
16
|
+
contractHash: string;
|
|
17
|
+
}
|
|
18
|
+
export interface AppResourceManifest {
|
|
19
|
+
schemaVersion: 'nightfuel.app-resources.v1';
|
|
20
|
+
resources: AppResourceDescriptor[];
|
|
21
|
+
}
|
|
22
|
+
declare const definitionsType: unique symbol;
|
|
23
|
+
export interface DefinedAppResources<T extends readonly AppResourceDefinition[] = readonly AppResourceDefinition[]> {
|
|
24
|
+
readonly manifest: AppResourceManifest;
|
|
25
|
+
readonly [definitionsType]?: T;
|
|
26
|
+
}
|
|
27
|
+
export interface AppResourcePrincipal {
|
|
28
|
+
type: 'user' | 'agent' | 'workflow' | 'mcp_connection';
|
|
29
|
+
id: string;
|
|
30
|
+
workspaceId: string;
|
|
31
|
+
projectId: string;
|
|
32
|
+
}
|
|
33
|
+
export interface AppResourceReadContext {
|
|
34
|
+
resource: AppResourceDescriptor;
|
|
35
|
+
itemId: string;
|
|
36
|
+
principal: AppResourcePrincipal;
|
|
37
|
+
}
|
|
38
|
+
type ResourceReader<D extends AppResourceDefinition> = D extends AppResourceDefinition ? {
|
|
39
|
+
key: D['key'];
|
|
40
|
+
version: D['version'];
|
|
41
|
+
read(context: AppResourceReadContext): Promise<InferEventSchema<D['item']> | null> | InferEventSchema<D['item']> | null;
|
|
42
|
+
} : never;
|
|
43
|
+
export interface AppResourceReply {
|
|
44
|
+
appId: string;
|
|
45
|
+
key: string;
|
|
46
|
+
version: number;
|
|
47
|
+
contractHash: string;
|
|
48
|
+
itemId: string;
|
|
49
|
+
found: boolean;
|
|
50
|
+
item: Record<string, unknown> | null;
|
|
51
|
+
}
|
|
52
|
+
export function defineAppResources<const T extends readonly AppResourceDefinition[]>(definitions: T): DefinedAppResources<T>;
|
|
53
|
+
export function createAppResourceHandler<const T extends readonly AppResourceDefinition[]>(resources: DefinedAppResources<T>, options: {
|
|
54
|
+
authorize(context: AppResourceReadContext): boolean | Promise<boolean>;
|
|
55
|
+
readers: ResourceReader<T[number]>[];
|
|
56
|
+
secret?: string;
|
|
57
|
+
appId?: string;
|
|
58
|
+
}): (request: { headers: Record<string, string | string[] | undefined>; method: string; path: string; rawBody: Uint8Array }) => Promise<AppResourceReply>;
|
package/resources.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The SDK and platform consume this same bounded schema dialect. Contracts are
|
|
4
|
+
// metadata: discovering them never evaluates App code or starts its runtime.
|
|
5
|
+
const Schema = require('./events');
|
|
6
|
+
const MANIFEST_PATH = '.nightfuel/resources.json';
|
|
7
|
+
const SCHEMA_VERSION = 'nightfuel.app-resources.v1';
|
|
8
|
+
const READ_PATH = '/_nightfuel/resources/read';
|
|
9
|
+
const MAX_BYTES = Schema.MAX_BYTES;
|
|
10
|
+
function resourceCallSecret(secret) {
|
|
11
|
+
if (typeof secret !== 'string' || !secret) fail('App resource verification secret is unavailable', 503);
|
|
12
|
+
return require('node:crypto').createHmac('sha256', secret).update('nightfuel.app-resource-read.v1').digest('hex');
|
|
13
|
+
}
|
|
14
|
+
const plain = value => value && typeof value === 'object' && !Array.isArray(value)
|
|
15
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
16
|
+
function fail(message, statusCode = 400, code = 'APP_RESOURCE_INVALID') {
|
|
17
|
+
throw Object.assign(new Error(message), { status: statusCode, statusCode, code });
|
|
18
|
+
}
|
|
19
|
+
function exact(value, keys, label) {
|
|
20
|
+
if (!plain(value) || Object.keys(value).some(key => !keys.includes(key))) fail(`Invalid ${label}`);
|
|
21
|
+
}
|
|
22
|
+
function text(value, label, max, required = false) {
|
|
23
|
+
if (value === undefined && !required) return '';
|
|
24
|
+
if (typeof value !== 'string' || value.length > max || (required && !value.trim())) fail(`Invalid ${label}`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function normalizeManifest(value) {
|
|
28
|
+
exact(value, ['schemaVersion', 'resources'], 'App resource manifest');
|
|
29
|
+
if (value.schemaVersion !== SCHEMA_VERSION || !Array.isArray(value.resources) || value.resources.length > 100
|
|
30
|
+
|| Buffer.byteLength(JSON.stringify(value)) > MAX_BYTES) fail('Invalid App resource manifest');
|
|
31
|
+
const identities = new Set();
|
|
32
|
+
const resources = value.resources.map(resource => {
|
|
33
|
+
exact(resource, ['key', 'version', 'title', 'description', 'itemSchema', 'contractHash'], 'resource');
|
|
34
|
+
if (typeof resource.key !== 'string' || !/^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*){0,7}$/.test(resource.key)
|
|
35
|
+
|| resource.key.length > 120 || !Number.isSafeInteger(resource.version) || resource.version < 1) fail('Invalid resource key or version');
|
|
36
|
+
const identity = `${resource.key}:${resource.version}`;
|
|
37
|
+
if (identities.has(identity)) fail('Duplicate resource key and version');
|
|
38
|
+
identities.add(identity);
|
|
39
|
+
const itemSchema = Schema.normalizeSchema(resource.itemSchema);
|
|
40
|
+
if (itemSchema.type !== 'object' || itemSchema.properties.id?.type !== 'string' || !itemSchema.required.includes('id')) {
|
|
41
|
+
fail('A resource item requires a string id field');
|
|
42
|
+
}
|
|
43
|
+
const contractHash = Schema.digest({ key: resource.key, version: resource.version, itemSchema });
|
|
44
|
+
if (resource.contractHash !== undefined && resource.contractHash !== contractHash) fail('Resource contract hash mismatch');
|
|
45
|
+
return { key: resource.key, version: resource.version,
|
|
46
|
+
title: text(resource.title, 'resource title', 160) || resource.key,
|
|
47
|
+
description: text(resource.description, 'resource description', 2000), itemSchema, contractHash };
|
|
48
|
+
}).sort((a, b) => a.key.localeCompare(b.key) || a.version - b.version);
|
|
49
|
+
return { schemaVersion: SCHEMA_VERSION, resources };
|
|
50
|
+
}
|
|
51
|
+
function freeze(value) {
|
|
52
|
+
if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value); }
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function defineAppResources(definitions) {
|
|
56
|
+
if (!Array.isArray(definitions)) fail('Resource definitions must be an array');
|
|
57
|
+
return freeze({ manifest: normalizeManifest({ schemaVersion: SCHEMA_VERSION,
|
|
58
|
+
resources: definitions.map(definition => {
|
|
59
|
+
exact(definition, ['key', 'version', 'title', 'description', 'item'], 'resource definition');
|
|
60
|
+
return { key: definition.key, version: definition.version, title: definition.title,
|
|
61
|
+
description: definition.description, itemSchema: definition.item };
|
|
62
|
+
}) }) });
|
|
63
|
+
}
|
|
64
|
+
function validateItem(contract, itemId, item) {
|
|
65
|
+
if (item === null) return;
|
|
66
|
+
Schema.validatePayload(contract.itemSchema, item, 'item');
|
|
67
|
+
if (item.id !== itemId) fail('App returned another resource item', 502, 'APP_RESOURCE_ID_MISMATCH');
|
|
68
|
+
}
|
|
69
|
+
function validatePrincipal(principal) {
|
|
70
|
+
exact(principal, ['type', 'id', 'workspaceId', 'projectId'], 'resource principal');
|
|
71
|
+
if (!['user', 'agent', 'workflow', 'mcp_connection'].includes(principal.type)
|
|
72
|
+
|| !/^[a-f\d]{24}$/i.test(principal.id || '')
|
|
73
|
+
|| !/^[a-f\d]{24}$/i.test(principal.workspaceId || '')
|
|
74
|
+
|| !/^[a-f\d]{24}$/i.test(principal.projectId || '')) fail('Invalid resource principal', 403);
|
|
75
|
+
}
|
|
76
|
+
function validateReply(contract, binding, value) {
|
|
77
|
+
exact(value, ['appId', 'key', 'version', 'contractHash', 'itemId', 'found', 'item'], 'resource reply');
|
|
78
|
+
for (const key of ['appId', 'key', 'version', 'contractHash', 'itemId']) {
|
|
79
|
+
if (value[key] !== binding[key]) fail('App resource reply does not match the requested contract', 502, 'APP_RESOURCE_REPLY_MISMATCH');
|
|
80
|
+
}
|
|
81
|
+
if (typeof value.found !== 'boolean' || value.found !== (value.item !== null)) fail('Invalid resource presence', 502);
|
|
82
|
+
if (Buffer.byteLength(JSON.stringify(value)) > MAX_BYTES) fail('App resource response is too large', 502);
|
|
83
|
+
validateItem(contract, binding.itemId, value.item);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A framework-neutral handler for a raw POST body. Authorization is mandatory
|
|
88
|
+
// and precedes record access. The App owns record-level policy; a schema or an
|
|
89
|
+
// HTTP method alone cannot prove that arbitrary App code has no side effects.
|
|
90
|
+
function createAppResourceHandler(resources, { authorize, readers, secret, appId } = {}) {
|
|
91
|
+
const manifest = normalizeManifest(resources?.manifest);
|
|
92
|
+
if (typeof authorize !== 'function' || !Array.isArray(readers)) fail('Resource authorization and readers are required');
|
|
93
|
+
const routes = new Map();
|
|
94
|
+
for (const reader of readers) {
|
|
95
|
+
const identity = `${reader.key}:${reader.version}`;
|
|
96
|
+
if (routes.has(identity) || typeof reader.read !== 'function'
|
|
97
|
+
|| !manifest.resources.some(r => r.key === reader.key && r.version === reader.version)) fail('Invalid resource reader');
|
|
98
|
+
routes.set(identity, reader.read);
|
|
99
|
+
}
|
|
100
|
+
if (routes.size !== manifest.resources.length) fail('Every published resource version needs a reader');
|
|
101
|
+
return async request => {
|
|
102
|
+
if (request?.method !== 'POST' || request?.path !== READ_PATH
|
|
103
|
+
|| !(request.rawBody instanceof Uint8Array) || request.rawBody.length > MAX_BYTES) fail('Invalid resource read request');
|
|
104
|
+
// Lazy require avoids the public SDK entrypoint's circular export.
|
|
105
|
+
// Generic Apphook tokens cannot assert a user/Agent identity. A separate
|
|
106
|
+
// signing purpose is required even if they address this same URL/body.
|
|
107
|
+
const claims = require('./index').verifyNightfuelCall({ ...request,
|
|
108
|
+
secret: resourceCallSecret(secret || process.env.NF_APP_CALL_SECRET), appId });
|
|
109
|
+
let input;
|
|
110
|
+
try { input = JSON.parse(Buffer.from(request.rawBody).toString('utf8')); } catch { fail('Invalid resource read JSON'); }
|
|
111
|
+
exact(input, ['appId', 'key', 'version', 'contractHash', 'itemId', 'principal'], 'resource read');
|
|
112
|
+
validatePrincipal(input.principal);
|
|
113
|
+
if (claims.appId !== input.appId || claims.projectId !== input.principal.projectId) fail('Resource principal scope mismatch', 403);
|
|
114
|
+
text(input.itemId, 'itemId', 512, true);
|
|
115
|
+
const contract = manifest.resources.find(r => r.key === input.key && r.version === input.version && r.contractHash === input.contractHash);
|
|
116
|
+
if (!contract) fail('The App does not serve this resource contract version', 409, 'APP_RESOURCE_VERSION_UNAVAILABLE');
|
|
117
|
+
const scope = freeze({ resource: contract, itemId: input.itemId, principal: input.principal });
|
|
118
|
+
if (await authorize(scope) !== true) fail('Resource item access denied', 403, 'APP_RESOURCE_ACCESS_DENIED');
|
|
119
|
+
const item = await routes.get(`${contract.key}:${contract.version}`)(scope);
|
|
120
|
+
return validateReply(contract, input, { appId: input.appId, key: contract.key, version: contract.version,
|
|
121
|
+
contractHash: contract.contractHash, itemId: input.itemId, found: item !== null, item });
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
module.exports = { MANIFEST_PATH, SCHEMA_VERSION, READ_PATH, MAX_BYTES, defineAppResources,
|
|
125
|
+
resourceSchema: Schema.eventSchema, normalizeManifest, validateItem, validateReply, createAppResourceHandler, resourceCallSecret };
|