@nightfuel-ai/app-sdk 0.3.0 → 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 CHANGED
@@ -300,3 +300,148 @@ MCP catalog. Selecting a version pins its exact hash and never follows “latest
300
300
  The first contract supports one item by ID. It grants no arbitrary database
301
301
  querying, source access or automatic synchronization. Explicit workflows can map
302
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/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
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';
2
5
  export { defineAppEvents, eventSchema } from './events';
3
6
  export type { AppEventClient, AppEventDefinitions, AppEventManifest, AppEventReceipt, DefinedAppEvents, EventPayload, InferEventSchema } from './events';
4
7
 
@@ -185,7 +188,9 @@ export interface NightfuelWorkflowClient {
185
188
  }
186
189
 
187
190
  export interface NightfuelAppSdk<T extends AppEventDefinitions = Record<string, never>> {
191
+ readonly maps: { sync(input: AppMapSync): Promise<AppMapReceipt> };
188
192
  readonly appId: string;
193
+ readonly items: AppItemsClient;
189
194
  /** Server-only credentials owned by this exact App; no access to other project keys. */
190
195
  readonly credentials: {
191
196
  store(input: AppCredentialReference & { value: string }): Promise<{ configured: true; reference: AppCredentialReference; vaultKeyId: string }>;
@@ -208,6 +213,22 @@ export interface NightfuelAppSdk<T extends AppEventDefinitions = Record<string,
208
213
  createIdempotencyKey(): string;
209
214
  }
210
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;
211
232
  export interface AppCalendarBlock { id: string; from: string; to: string }
212
233
  export interface AppCalendarSource {
213
234
  id: string; scope: string; resourceId: string; name: string; enabled: boolean;
package/index.js CHANGED
@@ -11,6 +11,9 @@ const { parseSseFrame, recoverableSseStream, terminalRunStatus } = require('./st
11
11
  const AppEvents = require('./events');
12
12
  const { defineAppEvents, eventSchema } = AppEvents;
13
13
  const { defineAppResources, resourceSchema, createAppResourceHandler } = require('./resources');
14
+ const { defineItemProjection, validateItemSync } = require('./items');
15
+ const { createItemWriteHandler } = require('./item-writes');
16
+ const { verifyNightfuelMcpCall } = require('./mcp');
14
17
 
15
18
  const METADATA_IDENTITY_URL =
16
19
  'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity';
@@ -191,7 +194,13 @@ function createNightfuelAppSdk(options = {}) {
191
194
  const integrationRequest = (path, input) => postJson(`/internal/hosting/apps/${appId}/${path}`, input, {}, AbortSignal.timeout(15000));
192
195
 
193
196
  return Object.freeze({
197
+ maps: Object.freeze({ sync: input => integrationRequest('maps/sync', require('./maps').validateMapSync(input)) }),
194
198
  appId,
199
+ items: Object.freeze({
200
+ async sync(definition, input) {
201
+ return integrationRequest('items/sync', validateItemSync(definition, input));
202
+ },
203
+ }),
195
204
  credentials: Object.freeze({
196
205
  store: input => integrationRequest('credentials/store', input),
197
206
  read: input => integrationRequest('credentials/read', input),
@@ -361,6 +370,9 @@ function verifyNightfuelCall({ headers, method, path, rawBody, secret, appId, no
361
370
  }
362
371
 
363
372
  module.exports = {
373
+ verifyNightfuelMcpCall,
374
+ defineItemProjection,
375
+ createItemWriteHandler,
364
376
  defineAppResources,
365
377
  resourceSchema,
366
378
  createAppResourceHandler,
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.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,11 @@
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",
17
22
  "events.js",
18
23
  "events.d.ts",
19
24
  "resources.js",