@nightfuel-ai/app-sdk 0.1.2 → 0.3.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
@@ -133,3 +133,170 @@ 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`.
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,7 @@
1
+ import type { AppEventClient, AppEventDefinitions, DefinedAppEvents } from './events';
2
+ export { defineAppEvents, eventSchema } from './events';
3
+ export type { AppEventClient, AppEventDefinitions, AppEventManifest, AppEventReceipt, DefinedAppEvents, EventPayload, InferEventSchema } from './events';
4
+
1
5
  export interface NightfuelAppSdkErrorOptions {
2
6
  status?: number;
3
7
  code?: string;
@@ -180,14 +184,39 @@ export interface NightfuelWorkflowClient {
180
184
  ): AsyncGenerator<NightfuelWorkflowEvent<T>, void, undefined>;
181
185
  }
182
186
 
183
- export interface NightfuelAppSdk {
187
+ export interface NightfuelAppSdk<T extends AppEventDefinitions = Record<string, never>> {
184
188
  readonly appId: string;
189
+ /** Server-only credentials owned by this exact App; no access to other project keys. */
190
+ readonly credentials: {
191
+ store(input: AppCredentialReference & { value: string }): Promise<{ configured: true; reference: AppCredentialReference; vaultKeyId: string }>;
192
+ read(input: AppCredentialReference): Promise<{ value: string }>;
193
+ remove(input: AppCredentialReference): Promise<{ configured: false; reference: AppCredentialReference }>;
194
+ };
195
+ readonly calendar: {
196
+ connect(input: { id: string; scope: string; resourceId: string; name: string; url: string }): Promise<AppCalendarSource>;
197
+ snapshot(input: { id: string; scope: string; refresh?: boolean }): Promise<{ source: AppCalendarSource; blocks: AppCalendarBlock[]; revision: number }>;
198
+ remove(input: { id: string; scope: string }): Promise<{ removed: true }>;
199
+ upsert(input: { id: string; scope: string; resourceId: string; from: string; to: string; title: string; version: number; active: boolean }): Promise<{ saved: true; version: number }>;
200
+ };
201
+ /** Durable platform callbacks that wake a scaled-to-zero App using its signed call identity. */
202
+ readonly jobs: {
203
+ ensure(input: { key: string; path: string; everySeconds: number }): Promise<{ key: string; enabled: boolean; everySeconds: number }>;
204
+ };
185
205
  readonly data: NightfuelDataClient;
186
206
  readonly workflows: NightfuelWorkflowClient;
207
+ readonly events: AppEventClient<T>;
187
208
  createIdempotencyKey(): string;
188
209
  }
210
+ export interface AppCredentialReference { namespace: string; scope: string; name: string }
211
+ export interface AppCalendarBlock { id: string; from: string; to: string }
212
+ export interface AppCalendarSource {
213
+ id: string; scope: string; resourceId: string; name: string; enabled: boolean;
214
+ status: 'pending' | 'ok' | 'error'; lastSyncedAt: string | null; error: string | null; exportUrl: string | null;
215
+ }
189
216
 
190
- export function createNightfuelAppSdk(options?: NightfuelAppSdkOptions): NightfuelAppSdk;
217
+ export function createNightfuelAppSdk<const T extends AppEventDefinitions = Record<string, never>>(
218
+ options?: NightfuelAppSdkOptions & { events?: DefinedAppEvents<T> }
219
+ ): NightfuelAppSdk<T>;
191
220
 
192
221
  /** The claims Nightfuel signs into an Apphook request. */
193
222
  export interface NightfuelCallClaims {
@@ -225,3 +254,5 @@ export function verifyNightfuelCall(input: {
225
254
  appId?: string;
226
255
  now?: number;
227
256
  }): NightfuelCallClaims;
257
+ export { defineAppResources, resourceSchema, createAppResourceHandler } from './resources';
258
+ export type { AppResourceDefinition, AppResourceDescriptor, AppResourceManifest, DefinedAppResources, AppResourcePrincipal, AppResourceReadContext, AppResourceReply } from './resources';
package/index.js CHANGED
@@ -8,6 +8,9 @@
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');
11
14
 
12
15
  const METADATA_IDENTITY_URL =
13
16
  'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity';
@@ -113,6 +116,7 @@ async function responsePayload(response) {
113
116
  }
114
117
 
115
118
  function createNightfuelAppSdk(options = {}) {
119
+ const eventManifest = options.events ? AppEvents.normalizeManifest(options.events.manifest) : null;
116
120
  requiredServerRuntime();
117
121
  const env = options.env || process.env;
118
122
  const fetchImpl = options.fetch || globalThis.fetch;
@@ -175,17 +179,33 @@ function createNightfuelAppSdk(options = {}) {
175
179
  return response;
176
180
  }
177
181
 
178
- async function postJson(path, body, headers = {}) {
182
+ async function postJson(path, body, headers = {}, signal) {
179
183
  const response = await request(path, {
180
184
  method: 'POST',
181
185
  headers: { 'Content-Type': 'application/json', ...headers },
182
186
  body: JSON.stringify(body),
187
+ signal,
183
188
  });
184
189
  return responsePayload(response);
185
190
  }
191
+ const integrationRequest = (path, input) => postJson(`/internal/hosting/apps/${appId}/${path}`, input, {}, AbortSignal.timeout(15000));
186
192
 
187
193
  return Object.freeze({
188
194
  appId,
195
+ credentials: Object.freeze({
196
+ store: input => integrationRequest('credentials/store', input),
197
+ read: input => integrationRequest('credentials/read', input),
198
+ remove: input => integrationRequest('credentials/remove', input),
199
+ }),
200
+ calendar: Object.freeze({
201
+ connect: input => integrationRequest('calendar/connect', input),
202
+ snapshot: input => integrationRequest('calendar/snapshot', input),
203
+ remove: input => integrationRequest('calendar/remove', input),
204
+ upsert: input => integrationRequest('calendar/upsert', input),
205
+ }),
206
+ jobs: Object.freeze({
207
+ ensure: input => integrationRequest('jobs/ensure', input),
208
+ }),
189
209
  data: Object.freeze({
190
210
  query: (input) => postJson(`/internal/hosting/apps/${appId}/data/query`, input || {}),
191
211
  semantic: (input) => postJson(`/internal/hosting/apps/${appId}/data/semantic`, input || {}),
@@ -201,6 +221,23 @@ function createNightfuelAppSdk(options = {}) {
201
221
  );
202
222
  },
203
223
  }),
224
+ events: Object.freeze({
225
+ async emit(eventKey, payload, options = {}) {
226
+ const definition = eventManifest?.events.find(event => event.key === eventKey);
227
+ if (!definition) throw new NightfuelAppSdkError('Declare this event with defineAppEvents before emitting it', {
228
+ code: 'APP_EVENT_UNDECLARED',
229
+ });
230
+ const key = idempotencyKey(options.idempotencyKey);
231
+ AppEvents.validatePayload(definition.payloadSchema, payload);
232
+ AppEvents.validatePayload({ type: 'string', format: 'date-time' }, options.occurredAt, 'occurredAt');
233
+ const body = { eventKey, version: definition.version, contractHash: definition.contractHash,
234
+ occurredAt: options.occurredAt, payload };
235
+ if (Buffer.byteLength(JSON.stringify(body), 'utf8') > AppEvents.MAX_BYTES) {
236
+ throw new NightfuelAppSdkError('App event exceeds 256 KiB', { code: 'APP_EVENT_TOO_LARGE', status: 413 });
237
+ }
238
+ return postJson(`/internal/hosting/apps/${appId}/events`, body, { 'Idempotency-Key': key }, AbortSignal.timeout(15000));
239
+ },
240
+ }),
204
241
  workflows: Object.freeze({
205
242
  async run(rawBindingId, input = {}, options = {}) {
206
243
  const resolvedBindingId = bindingId(rawBindingId);
@@ -324,6 +361,11 @@ function verifyNightfuelCall({ headers, method, path, rawBody, secret, appId, no
324
361
  }
325
362
 
326
363
  module.exports = {
364
+ defineAppResources,
365
+ resourceSchema,
366
+ createAppResourceHandler,
367
+ defineAppEvents,
368
+ eventSchema,
327
369
  NightfuelAppSdkError,
328
370
  createNightfuelAppSdk,
329
371
  terminalRunStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightfuel-ai/app-sdk",
3
- "version": "0.1.2",
3
+ "version": "0.3.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,10 @@
14
14
  "files": [
15
15
  "index.js",
16
16
  "index.d.ts",
17
+ "events.js",
18
+ "events.d.ts",
19
+ "resources.js",
20
+ "resources.d.ts",
17
21
  "stream.js",
18
22
  "README.md",
19
23
  "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 };