spfn 0.3.0-beta.13 → 0.3.0-beta.15

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.
@@ -0,0 +1,497 @@
1
+ // src/surface/command-path.ts
2
+ var PATH_BASE = "https://spfn.invalid";
3
+ function unstablePathBecause(path, prefix) {
4
+ let url;
5
+ try {
6
+ url = new URL(path, PATH_BASE);
7
+ } catch {
8
+ return "is not a valid URL path";
9
+ }
10
+ if (url.origin !== PATH_BASE || url.search !== "" || url.hash !== "") {
11
+ return "is not a plain absolute path";
12
+ }
13
+ if (url.pathname !== path) {
14
+ return "changes when URL-normalized";
15
+ }
16
+ if (!url.pathname.startsWith(prefix)) {
17
+ return `is outside ${prefix}`;
18
+ }
19
+ const slashCount = (path.match(/\//g) ?? []).length;
20
+ let decoded = path;
21
+ for (let depth = 0; depth < 8; depth++) {
22
+ let next;
23
+ try {
24
+ next = decodeURIComponent(decoded);
25
+ } catch {
26
+ return "contains malformed percent encoding";
27
+ }
28
+ if (next.includes("\\") || (next.match(/\//g) ?? []).length !== slashCount || next.split("/").some((segment) => segment === "." || segment === "..")) {
29
+ return "contains encoded path separators or dot segments";
30
+ }
31
+ if (next === decoded) {
32
+ return null;
33
+ }
34
+ decoded = next;
35
+ }
36
+ return "uses too many percent-encoding layers";
37
+ }
38
+ function climbsOutOfItsSegment(value) {
39
+ let decoded = value;
40
+ for (let depth = 0; depth < 8; depth++) {
41
+ if (decoded.split(/[/\\]/).some((part) => part === "." || part === "..")) {
42
+ return true;
43
+ }
44
+ let next;
45
+ try {
46
+ next = decodeURIComponent(decoded);
47
+ } catch {
48
+ return false;
49
+ }
50
+ if (next === decoded) {
51
+ return false;
52
+ }
53
+ decoded = next;
54
+ }
55
+ return true;
56
+ }
57
+ function buildCommandPath(command, params, query, namespace) {
58
+ const templateReason = unstablePathBecause(command.path, `${namespace}/`);
59
+ if (templateReason !== null) {
60
+ throw new Error(`Refusing command path ${JSON.stringify(command.path)} because it ${templateReason}.`);
61
+ }
62
+ const path = command.path.replace(/:([A-Za-z0-9_]+)/g, (_match, name) => {
63
+ const value = params[name];
64
+ if (value === void 0) {
65
+ throw new Error(`Missing path parameter "${name}" (pass --param ${name}=<value>).`);
66
+ }
67
+ if (value === "") {
68
+ throw new Error(`Path parameter "${name}" cannot be empty.`);
69
+ }
70
+ if (climbsOutOfItsSegment(value)) {
71
+ throw new Error(
72
+ `Path parameter "${name}" contains dot segments and cannot be sent: ${JSON.stringify(value)}.`
73
+ );
74
+ }
75
+ return encodeURIComponent(value);
76
+ });
77
+ const search = new URLSearchParams(query).toString();
78
+ return search.length > 0 ? `${path}?${search}` : path;
79
+ }
80
+
81
+ // src/surface/plain.ts
82
+ function plain(value) {
83
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
84
+ }
85
+
86
+ // src/surface/describe.ts
87
+ var SECTIONS = [
88
+ { key: "params", label: "path parameters", flag: "--param" },
89
+ { key: "query", label: "query parameters", flag: "--query" },
90
+ { key: "body", label: "body fields", flag: "--data" }
91
+ ];
92
+ function isSchema(value) {
93
+ return value !== null && typeof value === "object" && !Array.isArray(value);
94
+ }
95
+ var MAX_SCHEMA_DEPTH = 12;
96
+ function typeName(schema) {
97
+ if (Array.isArray(schema.enum)) {
98
+ return schema.enum.every((v) => typeof v === "string") ? "string" : "value";
99
+ }
100
+ const variants = schema.anyOf ?? schema.oneOf;
101
+ if (Array.isArray(variants)) {
102
+ const names = variants.filter(isSchema).map(typeName).filter((name) => name !== "null");
103
+ return [...new Set(names)].join("|") || "value";
104
+ }
105
+ const type = schema.type;
106
+ if (Array.isArray(type)) {
107
+ return type.filter((t) => t !== "null").join("|") || "value";
108
+ }
109
+ if (type === "array") {
110
+ return isSchema(schema.items) ? `${typeName(schema.items)}[]` : "array";
111
+ }
112
+ return typeof type === "string" ? type : "value";
113
+ }
114
+ function constraintNotes(schema) {
115
+ const notes = [];
116
+ if (Array.isArray(schema.enum)) {
117
+ notes.push(`one of: ${schema.enum.map((v) => String(v)).join(", ")}`);
118
+ }
119
+ const min = schema.minimum ?? schema.minLength ?? schema.minItems;
120
+ const max = schema.maximum ?? schema.maxLength ?? schema.maxItems;
121
+ if (min !== void 0 && max !== void 0) {
122
+ notes.push(`${min}\u2013${max}`);
123
+ } else if (min !== void 0) {
124
+ notes.push(`min ${min}`);
125
+ } else if (max !== void 0) {
126
+ notes.push(`max ${max}`);
127
+ }
128
+ if (typeof schema.format === "string") {
129
+ notes.push(schema.format);
130
+ }
131
+ if (typeof schema.pattern === "string") {
132
+ notes.push(`matches ${schema.pattern}`);
133
+ }
134
+ if (schema.default !== void 0) {
135
+ notes.push(`default ${JSON.stringify(schema.default)}`);
136
+ }
137
+ if (typeof schema.description === "string" && schema.description.length > 0) {
138
+ notes.push(schema.description);
139
+ }
140
+ return notes;
141
+ }
142
+ function collectFields(schema, prefix = "", depth = 0) {
143
+ const properties = isSchema(schema.properties) ? schema.properties : {};
144
+ const required = Array.isArray(schema.required) ? schema.required : [];
145
+ const rows = [];
146
+ for (const [name, raw] of Object.entries(properties)) {
147
+ if (!isSchema(raw)) {
148
+ continue;
149
+ }
150
+ const path = prefix ? `${prefix}.${plain(name)}` : plain(name);
151
+ const nested = isSchema(raw.properties) && depth < MAX_SCHEMA_DEPTH ? collectFields(raw, path, depth + 1) : [];
152
+ if (nested.length > 0) {
153
+ rows.push(...nested);
154
+ continue;
155
+ }
156
+ rows.push({
157
+ name: path,
158
+ type: plain(typeName(raw)),
159
+ requirement: required.includes(name) ? "required" : "optional",
160
+ notes: plain(constraintNotes(raw).join(", "))
161
+ });
162
+ }
163
+ return rows;
164
+ }
165
+ function pad(value, width) {
166
+ return value + " ".repeat(Math.max(0, width - value.length));
167
+ }
168
+ function renderSection(label, flag, rows) {
169
+ const nameWidth = Math.max(...rows.map((r) => r.name.length));
170
+ const typeWidth = Math.max(...rows.map((r) => r.type.length));
171
+ const lines = [` ${label} (${flag})`];
172
+ for (const row of rows) {
173
+ const notes = row.notes.length > 0 ? ` ${row.notes}` : "";
174
+ lines.push(` ${pad(row.name, nameWidth)} ${pad(row.type, typeWidth)} ${pad(row.requirement, 8)}${notes}`.trimEnd());
175
+ }
176
+ return lines;
177
+ }
178
+ function renderCommandUsage(command, invoke) {
179
+ const lines = [`${plain(command.name)} ${command.method} ${plain(command.path)}`, ""];
180
+ let described = false;
181
+ if (command.summary) {
182
+ lines.push(` ${plain(command.summary)}`, "");
183
+ }
184
+ if (command.effect || command.scopes) {
185
+ lines.push(` Effect: ${command.effect ?? "unknown"}`);
186
+ lines.push(` Scopes: ${command.scopes?.map(plain).join(", ") || "(not declared)"}`, "");
187
+ }
188
+ for (const section of SECTIONS) {
189
+ const schema = command.input[section.key];
190
+ if (!isSchema(schema)) {
191
+ continue;
192
+ }
193
+ const rows = collectFields(schema);
194
+ if (rows.length === 0) {
195
+ continue;
196
+ }
197
+ lines.push(...renderSection(section.label, section.flag, rows), "");
198
+ described = true;
199
+ }
200
+ if (!described) {
201
+ lines.push(" Takes no input.", "");
202
+ }
203
+ if (invoke !== void 0) {
204
+ lines.push(` Invoke: ${invoke} ${plain(command.name)}${exampleFlags(command)}`);
205
+ }
206
+ return lines.join("\n").replace(/\n+$/, "");
207
+ }
208
+ function exampleFlags(command) {
209
+ const parts = [];
210
+ for (const section of SECTIONS) {
211
+ const schema = command.input[section.key];
212
+ if (!isSchema(schema)) {
213
+ continue;
214
+ }
215
+ const [first] = collectFields(schema);
216
+ if (!first) {
217
+ continue;
218
+ }
219
+ parts.push(section.key === "body" ? ` --data '{"${first.name}": ...}'` : ` ${section.flag} ${first.name}=<value>`);
220
+ }
221
+ return parts.join("");
222
+ }
223
+
224
+ // src/surface/manifest-trust.ts
225
+ function trustedManifest(body, reader) {
226
+ const manifest = body;
227
+ if (manifest?.manifestVersion !== 1 || !Array.isArray(manifest.commands)) {
228
+ throw new Error("The manifest answer has an unknown shape.");
229
+ }
230
+ const modules = usableModules(manifest.modules, reader);
231
+ return {
232
+ manifestVersion: 1,
233
+ ...manifest.modules !== void 0 ? { modules } : {},
234
+ commands: usableCommands(manifest.commands, new Set(modules.map((module) => module.id)), reader)
235
+ };
236
+ }
237
+ var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
238
+ var EFFECTS = /* @__PURE__ */ new Set(["read", "write", "destructive"]);
239
+ var MODULE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
240
+ var CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
241
+ var MAX_ID_LENGTH = 64;
242
+ var MAX_SOURCE_LENGTH = 128;
243
+ var MAX_SUMMARY_LENGTH = 500;
244
+ var MAX_SCOPES = 64;
245
+ var MAX_SCOPE_LENGTH = 128;
246
+ function unusableBecause(command, reader) {
247
+ if (typeof command?.name !== "string" || command.name.length === 0) {
248
+ return "it has no name";
249
+ }
250
+ if (typeof command.method !== "string" || !METHODS.has(command.method)) {
251
+ return `its method is ${JSON.stringify(command.method)}`;
252
+ }
253
+ if (typeof command.path !== "string") {
254
+ return `its path ${JSON.stringify(command.path)} is not a string`;
255
+ }
256
+ const pathReason = unstablePathBecause(command.path, reader.prefix);
257
+ if (pathReason !== null) {
258
+ return `its path ${JSON.stringify(command.path)} ${pathReason}`;
259
+ }
260
+ return null;
261
+ }
262
+ function usableCommands(commands, moduleIds, reader) {
263
+ const usable = [];
264
+ for (const command of commands) {
265
+ const reason = unusableBecause(command, reader);
266
+ if (reason !== null) {
267
+ reader.warn(
268
+ `Ignoring a command the ${reader.namespace} manifest announced: ${reason}.`,
269
+ { kind: "command", reason }
270
+ );
271
+ continue;
272
+ }
273
+ const normalized = {
274
+ name: command.name,
275
+ method: command.method,
276
+ path: command.path,
277
+ input: isPlainObject(command.input) ? command.input : {}
278
+ };
279
+ const metadataReason = unusableModuleMetadataBecause(command, moduleIds, reader);
280
+ if (metadataReason === null && command.module !== void 0) {
281
+ normalized.module = command.module;
282
+ normalized.summary = command.summary;
283
+ normalized.effect = command.effect;
284
+ normalized.scopes = [...command.scopes];
285
+ } else if (metadataReason !== null) {
286
+ normalized.metadataRejected = true;
287
+ reader.warn(
288
+ `Ignoring module metadata for command "${plain(command.name)}": ${metadataReason}. Its effect is unknown, so treat it as destructive.`,
289
+ { kind: "metadata", command: command.name, reason: metadataReason }
290
+ );
291
+ }
292
+ usable.push(normalized);
293
+ }
294
+ return usable;
295
+ }
296
+ function unusableModuleMetadataBecause(command, moduleIds, reader) {
297
+ const carriesMetadata = command.module !== void 0 || command.summary !== void 0 || command.effect !== void 0 || command.scopes !== void 0;
298
+ if (!carriesMetadata) {
299
+ return null;
300
+ }
301
+ if (typeof command.module !== "string" || !moduleIds.has(command.module)) {
302
+ return "it names no usable module";
303
+ }
304
+ if (!command.name.startsWith(`${command.module}.`)) {
305
+ return "its qualified name does not start with the module id";
306
+ }
307
+ if (!command.path.startsWith(`${reader.prefix}${command.module}/`)) {
308
+ return "its path is outside the module namespace";
309
+ }
310
+ if (typeof command.summary !== "string" || command.summary.length === 0 || command.summary.length > MAX_SUMMARY_LENGTH) {
311
+ return "its summary is missing or too long";
312
+ }
313
+ if (typeof command.effect !== "string" || !EFFECTS.has(command.effect)) {
314
+ return "its effect is unknown";
315
+ }
316
+ if (!Array.isArray(command.scopes) || command.scopes.length === 0 || command.scopes.length > MAX_SCOPES || command.scopes.some((scope) => typeof scope !== "string" || scope.length === 0 || scope.length > MAX_SCOPE_LENGTH)) {
317
+ return "its scopes are missing or invalid";
318
+ }
319
+ return null;
320
+ }
321
+ function usableModules(value, reader) {
322
+ if (value === void 0) {
323
+ return [];
324
+ }
325
+ if (!Array.isArray(value)) {
326
+ reader.warn("Ignoring module metadata: modules is not an array.", {
327
+ kind: "modules",
328
+ reason: "modules is not an array"
329
+ });
330
+ return [];
331
+ }
332
+ const modules = [];
333
+ const claimed = /* @__PURE__ */ new Set();
334
+ for (const raw of value) {
335
+ const reason = unusableModuleBecause(raw, claimed);
336
+ if (reason !== null) {
337
+ reader.warn(
338
+ `Ignoring a module the ${reader.namespace} manifest announced: ${reason}.`,
339
+ { kind: "module", reason }
340
+ );
341
+ continue;
342
+ }
343
+ const module = raw;
344
+ claimed.add(module.id);
345
+ modules.push({
346
+ id: module.id,
347
+ source: module.source,
348
+ contractVersion: module.contractVersion,
349
+ summary: module.summary
350
+ });
351
+ }
352
+ return modules;
353
+ }
354
+ function unusableModuleBecause(value, claimed) {
355
+ if (!isPlainObject(value)) {
356
+ return "it is not an object";
357
+ }
358
+ const id = value.id;
359
+ if (typeof id !== "string" || id.length > MAX_ID_LENGTH || !MODULE_ID.test(id)) {
360
+ return "its id is invalid";
361
+ }
362
+ if (claimed.has(id)) {
363
+ return `id "${id}" is duplicated`;
364
+ }
365
+ if (typeof value.source !== "string" || value.source.length === 0 || value.source.length > MAX_SOURCE_LENGTH) {
366
+ return `module "${id}" has an invalid source`;
367
+ }
368
+ if (typeof value.contractVersion !== "string" || !CONTRACT_VERSION.test(value.contractVersion)) {
369
+ return `module "${id}" has an invalid contract version`;
370
+ }
371
+ if (typeof value.summary !== "string" || value.summary.length === 0 || value.summary.length > MAX_SUMMARY_LENGTH) {
372
+ return `module "${id}" has an invalid summary`;
373
+ }
374
+ return null;
375
+ }
376
+ function isPlainObject(value) {
377
+ return value !== null && typeof value === "object" && !Array.isArray(value);
378
+ }
379
+
380
+ // src/surface/transport.ts
381
+ function isLoopback(hostname) {
382
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".localhost");
383
+ }
384
+ function transportRefusalOf(url) {
385
+ if (url.protocol === "https:" || url.protocol === "http:" && isLoopback(url.hostname)) {
386
+ return null;
387
+ }
388
+ return url.protocol === "http:" ? "cleartext-http" : "not-http";
389
+ }
390
+ function parseAppUrl(appUrl) {
391
+ try {
392
+ return new URL(appUrl);
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+ function assertTransportSafe(appUrl) {
398
+ const url = parseAppUrl(appUrl);
399
+ if (url === null) {
400
+ throw new Error(`Invalid app URL: ${appUrl}`);
401
+ }
402
+ const refusal = transportRefusalOf(url);
403
+ if (refusal === "cleartext-http") {
404
+ throw new Error(`Refusing to talk to ${appUrl} over http: a credential would cross it in the clear. Use https. http is allowed only against localhost.`);
405
+ }
406
+ if (refusal === "not-http") {
407
+ throw new Error(`App URL must be https, got "${url.protocol}//". http is allowed only against localhost.`);
408
+ }
409
+ }
410
+
411
+ // src/surface/client.ts
412
+ var NAMESPACE = /^(?:\/[A-Za-z0-9_-]+)+$/;
413
+ var SurfaceManifestError = class extends Error {
414
+ constructor(message, status) {
415
+ super(message);
416
+ this.status = status;
417
+ this.name = "SurfaceManifestError";
418
+ }
419
+ };
420
+ function joinUrl(appUrl, path) {
421
+ return appUrl.replace(/\/+$/, "") + path;
422
+ }
423
+ async function request(appUrl, token, method, path, body) {
424
+ const response = await fetch(joinUrl(appUrl, path), {
425
+ method,
426
+ headers: {
427
+ Authorization: `Bearer ${token}`,
428
+ ...body !== void 0 ? { "Content-Type": "application/json" } : {}
429
+ },
430
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
431
+ });
432
+ const text = await response.text();
433
+ let parsed = text;
434
+ try {
435
+ parsed = text.length > 0 ? JSON.parse(text) : null;
436
+ } catch {
437
+ }
438
+ return { status: response.status, body: parsed };
439
+ }
440
+ function manifestRefusal(namespace, status) {
441
+ if (status === 401 || status === 403) {
442
+ return new SurfaceManifestError(`The app refused the token (GET ${namespace}/_manifest answered ${status}).`, status);
443
+ }
444
+ if (status === 404) {
445
+ return new SurfaceManifestError(`No surface at ${namespace} on this app (GET ${namespace}/_manifest answered 404).`, status);
446
+ }
447
+ return new SurfaceManifestError(`Manifest request failed with status ${status}.`, status);
448
+ }
449
+ function assertNamespace(namespace) {
450
+ if (typeof namespace !== "string" || !NAMESPACE.test(namespace)) {
451
+ throw new Error(
452
+ `Surface namespace ${JSON.stringify(namespace)} is invalid. Use an absolute path of [A-Za-z0-9_-] segments without a trailing slash, such as "/_app".`
453
+ );
454
+ }
455
+ }
456
+ function findCommand(manifest, name) {
457
+ const command = manifest.commands.find((candidate) => candidate.name === name);
458
+ if (!command) {
459
+ throw new Error(`Unknown command "${plain(name)}".`);
460
+ }
461
+ return command;
462
+ }
463
+ function createSurfaceClient(options) {
464
+ assertTransportSafe(options.appUrl);
465
+ assertNamespace(options.namespace);
466
+ const { appUrl, namespace } = options;
467
+ const token = async () => typeof options.token === "function" ? await options.token() : options.token;
468
+ const reader = { namespace, prefix: `${namespace}/`, warn: options.onWarning ?? (() => void 0) };
469
+ const manifest = async () => {
470
+ const { status, body } = await request(appUrl, await token(), "GET", `${namespace}/_manifest`);
471
+ if (status !== 200) {
472
+ throw manifestRefusal(namespace, status);
473
+ }
474
+ return trustedManifest(body, reader);
475
+ };
476
+ const invoke = async (command, input) => {
477
+ const path = buildCommandPath(command, input.params, input.query, namespace);
478
+ return await request(appUrl, await token(), command.method, path, input.body);
479
+ };
480
+ return {
481
+ manifest,
482
+ invoke,
483
+ describe: (fetched, name) => renderCommandUsage(findCommand(fetched, name)),
484
+ call: async (name, input) => await invoke(findCommand(await manifest(), name), input)
485
+ };
486
+ }
487
+ export {
488
+ SurfaceManifestError,
489
+ assertTransportSafe,
490
+ buildCommandPath,
491
+ createSurfaceClient,
492
+ isLoopback,
493
+ joinUrl,
494
+ plain,
495
+ renderCommandUsage,
496
+ transportRefusalOf
497
+ };
package/package.json CHANGED
@@ -1,12 +1,25 @@
1
1
  {
2
2
  "name": "spfn",
3
- "version": "0.3.0-beta.13",
3
+ "version": "0.3.0-beta.15",
4
4
  "description": "Scaffold a full-stack TypeScript backend onto a Next.js app built with an AI coding agent: auth, database, typed routes and codegen, one fixed vertical slice per feature",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "spfn": "bin/spfn.js"
8
8
  },
9
9
  "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.js"
15
+ },
16
+ "./surface": {
17
+ "types": "./dist/surface.d.ts",
18
+ "import": "./dist/surface.js",
19
+ "require": "./dist/surface.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
10
23
  "files": [
11
24
  "dist",
12
25
  "bin"