@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6

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.
Files changed (45) hide show
  1. package/README.md +183 -4
  2. package/dist/authz/index.js +1 -381
  3. package/dist/authz/index.js.map +1 -1
  4. package/dist/db/index.d.ts +173 -27
  5. package/dist/db/index.js +192 -57
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/env/loader.js +24 -1
  8. package/dist/env/loader.js.map +1 -1
  9. package/dist/errors/index.js +1 -381
  10. package/dist/errors/index.js.map +1 -1
  11. package/dist/logger/index.js +0 -12
  12. package/dist/logger/index.js.map +1 -1
  13. package/dist/middleware/index.js +6 -387
  14. package/dist/middleware/index.js.map +1 -1
  15. package/dist/nextjs/index.d.ts +18 -1
  16. package/dist/nextjs/index.js +40 -1
  17. package/dist/nextjs/index.js.map +1 -1
  18. package/dist/nextjs/server.d.ts +34 -1
  19. package/dist/nextjs/server.js +14 -0
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/ops/index.d.ts +61 -6
  22. package/dist/ops/index.js +330 -30
  23. package/dist/ops/index.js.map +1 -1
  24. package/dist/server/index.js +24 -1
  25. package/dist/server/index.js.map +1 -1
  26. package/docs/file-upload.md +195 -333
  27. package/package.json +6 -5
  28. package/src/cache/README.md +330 -0
  29. package/src/codegen/README.md +516 -0
  30. package/src/config/README.md +326 -0
  31. package/src/contract/README.md +326 -0
  32. package/src/db/README.md +589 -0
  33. package/src/db/manager/README.md +500 -0
  34. package/src/db/schema/README.md +344 -0
  35. package/src/db/transaction/README.md +822 -0
  36. package/src/env/README.md +651 -0
  37. package/src/errors/README.md +429 -0
  38. package/src/event/README.md +736 -0
  39. package/src/job/README.md +514 -0
  40. package/src/logger/README.md +321 -0
  41. package/src/middleware/README.md +634 -0
  42. package/src/nextjs/README.md +608 -0
  43. package/src/route/README.md +738 -0
  44. package/src/security/README.md +100 -0
  45. package/src/server/README.md +704 -0
package/dist/ops/index.js CHANGED
@@ -319,13 +319,15 @@ function defineRouter(routes) {
319
319
  return createRouterInstance(routes);
320
320
  }
321
321
 
322
- // src/ops/manifest.ts
322
+ // src/ops/error.ts
323
323
  var OpsRouterError = class extends Error {
324
324
  constructor(message) {
325
325
  super(message);
326
326
  this.name = "OpsRouterError";
327
327
  }
328
328
  };
329
+
330
+ // src/ops/manifest.ts
329
331
  function isRouter(value) {
330
332
  return value !== null && typeof value === "object" && "routes" in value && "_routes" in value;
331
333
  }
@@ -383,6 +385,242 @@ function assertUnclaimedName(name, path, claimed) {
383
385
  claimed.set(name, path);
384
386
  }
385
387
 
388
+ // src/ops/ops-route.ts
389
+ var OPS_PATH_ROOT = "/_ops";
390
+ function toOpsPath(path) {
391
+ if (!path.startsWith("/")) {
392
+ throw new OpsRouterError(
393
+ `Ops route path "${path}" must start with "/". It is appended to "${OPS_PATH_ROOT}", so "${path}" would read as "${OPS_PATH_ROOT}${path}".`
394
+ );
395
+ }
396
+ if (path === "/") {
397
+ throw new OpsRouterError(
398
+ `Ops route path "/" names no command \u2014 "${OPS_PATH_ROOT}" itself is not a command.`
399
+ );
400
+ }
401
+ return OPS_PATH_ROOT + path;
402
+ }
403
+ function opsMethod(method) {
404
+ return (path) => route[method](toOpsPath(path));
405
+ }
406
+ var opsRoute = {
407
+ get: opsMethod("get"),
408
+ post: opsMethod("post"),
409
+ put: opsMethod("put"),
410
+ patch: opsMethod("patch"),
411
+ delete: opsMethod("delete")
412
+ };
413
+
414
+ // src/ops/route-overlap.ts
415
+ function routeSegments(path) {
416
+ return path.split("/").slice(1).map((raw) => {
417
+ if (raw === "*" || raw.endsWith("*")) {
418
+ return { kind: "wildcard", value: raw, optional: true };
419
+ }
420
+ if (raw.startsWith(":")) {
421
+ return {
422
+ kind: "parameter",
423
+ value: raw.slice(1).replace(/\?$/, ""),
424
+ optional: raw.endsWith("?")
425
+ };
426
+ }
427
+ return { kind: "static", value: raw, optional: false };
428
+ });
429
+ }
430
+ function customPattern(parameter) {
431
+ const openingBrace = parameter.indexOf("{");
432
+ return openingBrace >= 0 && parameter.endsWith("}") ? parameter.slice(openingBrace + 1, -1) : null;
433
+ }
434
+ function parameterAccepts(parameter, value) {
435
+ const pattern = customPattern(parameter);
436
+ if (pattern === null) {
437
+ return value.length > 0 && !value.includes("/");
438
+ }
439
+ try {
440
+ return new RegExp(`^(?:${pattern})$`).test(value);
441
+ } catch {
442
+ return true;
443
+ }
444
+ }
445
+ function remainingStaticPath(segments, from) {
446
+ const remaining = segments.slice(from);
447
+ return remaining.every((segment) => segment.kind === "static") ? remaining.map((segment) => segment.value).join("/") : null;
448
+ }
449
+ function opsRoutePatternsOverlap(firstPath, secondPath) {
450
+ const first = routeSegments(firstPath);
451
+ const second = routeSegments(secondPath);
452
+ const length = Math.max(first.length, second.length);
453
+ for (let index = 0; index < length; index++) {
454
+ const left = first[index];
455
+ const right = second[index];
456
+ if (!left || !right) {
457
+ const remaining = left ? first.slice(index) : second.slice(index);
458
+ return remaining.every((segment) => segment.optional || segment.kind === "wildcard");
459
+ }
460
+ if (left.kind === "wildcard" || right.kind === "wildcard") {
461
+ return true;
462
+ }
463
+ if (left.kind === "static" && right.kind === "static") {
464
+ if (left.value !== right.value) {
465
+ return false;
466
+ }
467
+ continue;
468
+ }
469
+ if (left.kind === "parameter" && right.kind === "static") {
470
+ const remaining = remainingStaticPath(second, index);
471
+ if (customPattern(left.value) !== null && remaining !== null && parameterAccepts(left.value, remaining)) {
472
+ return true;
473
+ }
474
+ if (!parameterAccepts(left.value, right.value)) {
475
+ return customPattern(left.value) !== null && remaining === null;
476
+ }
477
+ if (customPattern(left.value) !== null && first.length !== second.length) {
478
+ return true;
479
+ }
480
+ continue;
481
+ }
482
+ if (left.kind === "static" && right.kind === "parameter") {
483
+ const remaining = remainingStaticPath(first, index);
484
+ if (customPattern(right.value) !== null && remaining !== null && parameterAccepts(right.value, remaining)) {
485
+ return true;
486
+ }
487
+ if (!parameterAccepts(right.value, left.value)) {
488
+ return customPattern(right.value) !== null && remaining === null;
489
+ }
490
+ if (customPattern(right.value) !== null && first.length !== second.length) {
491
+ return true;
492
+ }
493
+ continue;
494
+ }
495
+ return true;
496
+ }
497
+ return true;
498
+ }
499
+
500
+ // src/ops/module.ts
501
+ var MODULE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
502
+ var COMMAND_NAME = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/;
503
+ var CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
504
+ var EFFECTS = /* @__PURE__ */ new Set(["read", "write", "destructive"]);
505
+ var OPS_PATH_BASE = "https://spfn.invalid";
506
+ function assertText(label, value) {
507
+ if (typeof value !== "string" || value.trim().length === 0) {
508
+ throw new OpsRouterError(`${label} must be a non-empty string.`);
509
+ }
510
+ }
511
+ function assertStableModulePath(moduleId, commandName, path) {
512
+ const label = `Ops command "${moduleId}.${commandName}" path "${path}"`;
513
+ const transportPath = path.replace(/[{}?#]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
514
+ let url;
515
+ try {
516
+ url = new URL(transportPath, OPS_PATH_BASE);
517
+ } catch {
518
+ throw new OpsRouterError(`${label} is not a valid URL path.`);
519
+ }
520
+ if (url.origin !== OPS_PATH_BASE || url.search !== "" || url.hash !== "" || url.pathname !== transportPath) {
521
+ throw new OpsRouterError(`${label} is not a stable plain absolute path.`);
522
+ }
523
+ const slashCount = (path.match(/\//g) ?? []).length;
524
+ let decoded = path;
525
+ for (let depth = 0; depth < 8; depth++) {
526
+ let next;
527
+ try {
528
+ next = decodeURIComponent(decoded);
529
+ } catch {
530
+ throw new OpsRouterError(`${label} contains malformed percent encoding.`);
531
+ }
532
+ if (next.includes("\\") || (next.match(/\//g) ?? []).length !== slashCount || next.split("/").some((segment) => segment === "." || segment === "..")) {
533
+ throw new OpsRouterError(`${label} contains encoded path separators or dot segments.`);
534
+ }
535
+ if (next === decoded) {
536
+ return;
537
+ }
538
+ decoded = next;
539
+ }
540
+ throw new OpsRouterError(`${label} uses too many percent-encoding layers.`);
541
+ }
542
+ function assertCliCallableModulePath(moduleId, commandName, path) {
543
+ const unsupported = path.split("/").slice(1).find((segment) => segment === "*" || segment.includes("*") || (segment.startsWith(":") ? !/^:[A-Za-z0-9_]+$/.test(segment) : !/^[A-Za-z0-9._~-]+$/.test(segment)));
544
+ if (unsupported !== void 0) {
545
+ throw new OpsRouterError(
546
+ `Ops command "${moduleId}.${commandName}" path segment "${unsupported}" is not CLI-callable. Module routes support URL-safe static segments and simple :name parameters only; optional, wildcard, and custom-regex parameters are not supported.`
547
+ );
548
+ }
549
+ }
550
+ function assertCommand(moduleId, name, command) {
551
+ if (!COMMAND_NAME.test(name)) {
552
+ throw new OpsRouterError(
553
+ `Ops module "${moduleId}" has invalid command name "${name}". Use dot-separated alphanumeric names.`
554
+ );
555
+ }
556
+ assertText(`Ops command "${moduleId}.${name}" summary`, command?.summary);
557
+ if (!EFFECTS.has(command?.effect)) {
558
+ throw new OpsRouterError(
559
+ `Ops command "${moduleId}.${name}" has invalid effect ${JSON.stringify(command?.effect)}.`
560
+ );
561
+ }
562
+ if (!Array.isArray(command?.scopes) || command.scopes.length === 0 || command.scopes.some((scope) => typeof scope !== "string" || scope.trim().length === 0)) {
563
+ throw new OpsRouterError(`Ops command "${moduleId}.${name}" must declare at least one non-empty scope.`);
564
+ }
565
+ const route2 = command?.route;
566
+ if (!route2 || typeof route2.handler !== "function" || !route2.method || !route2.path) {
567
+ throw new OpsRouterError(`Ops command "${moduleId}.${name}" must carry a complete ops route.`);
568
+ }
569
+ assertStableModulePath(moduleId, name, route2.path);
570
+ assertCliCallableModulePath(moduleId, name, route2.path);
571
+ const modulePath = `${OPS_PATH_ROOT}/${moduleId}/`;
572
+ if (!route2.path.startsWith(modulePath)) {
573
+ throw new OpsRouterError(
574
+ `Ops command "${moduleId}.${name}" is at "${route2.path}", outside "${modulePath}".`
575
+ );
576
+ }
577
+ if (route2.path.split("/").includes("..")) {
578
+ throw new OpsRouterError(`Ops command "${moduleId}.${name}" path climbs out of its module namespace.`);
579
+ }
580
+ }
581
+ function defineOpsModule(module) {
582
+ if (!MODULE_ID.test(module?.id ?? "")) {
583
+ throw new OpsRouterError(
584
+ `Ops module id ${JSON.stringify(module?.id)} is invalid. Use lower-kebab-case.`
585
+ );
586
+ }
587
+ assertText(`Ops module "${module.id}" source`, module.source);
588
+ assertText(`Ops module "${module.id}" summary`, module.summary);
589
+ if (!CONTRACT_VERSION.test(module.contractVersion)) {
590
+ throw new OpsRouterError(
591
+ `Ops module "${module.id}" contractVersion must be a semantic version.`
592
+ );
593
+ }
594
+ if (!module.commands || typeof module.commands !== "object" || Array.isArray(module.commands)) {
595
+ throw new OpsRouterError(`Ops module "${module.id}" commands must be an object.`);
596
+ }
597
+ const signatures = /* @__PURE__ */ new Map();
598
+ const claimedRoutes = [];
599
+ for (const [name, command] of Object.entries(module.commands)) {
600
+ assertCommand(module.id, name, command);
601
+ const signature = `${command.route.method} ${command.route.path}`;
602
+ const existing = signatures.get(signature);
603
+ if (existing) {
604
+ throw new OpsRouterError(
605
+ `Ops module "${module.id}" commands "${existing}" and "${name}" both use ${signature}.`
606
+ );
607
+ }
608
+ signatures.set(signature, name);
609
+ const overlapping = claimedRoutes.find((claim) => claim.method === command.route.method && opsRoutePatternsOverlap(claim.path, command.route.path));
610
+ if (overlapping) {
611
+ throw new OpsRouterError(
612
+ `Ops module "${module.id}" commands "${overlapping.name}" and "${name}" have overlapping ${command.route.method} routes ("${overlapping.path}" and "${command.route.path}").`
613
+ );
614
+ }
615
+ claimedRoutes.push({
616
+ name,
617
+ method: command.route.method,
618
+ path: command.route.path
619
+ });
620
+ }
621
+ return module;
622
+ }
623
+
386
624
  // src/ops/create-ops-router.ts
387
625
  var OPS_PATH_PREFIX = "/_ops/";
388
626
  var OPS_MANIFEST_PATH = "/_ops/_manifest";
@@ -451,50 +689,112 @@ function secureRoutes(routes, auth, inherited = []) {
451
689
  }
452
690
  return secured;
453
691
  }
692
+ function compileModules(modules, appCommands) {
693
+ const descriptors = [];
694
+ const commands = [];
695
+ const routes = {};
696
+ const moduleIds = /* @__PURE__ */ new Set();
697
+ const commandNames = new Set(appCommands.map((command) => command.name));
698
+ const routeSignatures = new Map(
699
+ appCommands.map((command) => [`${command.method} ${command.path}`, command.name])
700
+ );
701
+ for (const rawModule of modules) {
702
+ const module = defineOpsModule(rawModule);
703
+ if (moduleIds.has(module.id)) {
704
+ throw new OpsRouterError(`Two ops modules use id "${module.id}".`);
705
+ }
706
+ moduleIds.add(module.id);
707
+ descriptors.push({
708
+ id: module.id,
709
+ source: module.source,
710
+ contractVersion: module.contractVersion,
711
+ summary: module.summary
712
+ });
713
+ for (const [localName, definition] of Object.entries(module.commands)) {
714
+ const name = `${module.id}.${localName}`;
715
+ if (commandNames.has(name)) {
716
+ throw new OpsRouterError(`Two ops commands are named "${name}".`);
717
+ }
718
+ commandNames.add(name);
719
+ const method = definition.route.method;
720
+ const path = definition.route.path;
721
+ const signature = `${method} ${path}`;
722
+ const existing = routeSignatures.get(signature);
723
+ if (existing) {
724
+ throw new OpsRouterError(
725
+ `Ops commands "${existing}" and "${name}" both use ${signature}.`
726
+ );
727
+ }
728
+ const overlappingAppCommand = appCommands.find((command) => command.method === method && opsRoutePatternsOverlap(command.path, path));
729
+ if (overlappingAppCommand) {
730
+ throw new OpsRouterError(
731
+ `App ops command "${overlappingAppCommand.name}" at ${method} ${overlappingAppCommand.path} overlaps module command "${name}" at ${method} ${path}. Which one answers cannot depend on route registration order.`
732
+ );
733
+ }
734
+ routeSignatures.set(signature, name);
735
+ commands.push({
736
+ name,
737
+ module: module.id,
738
+ summary: definition.summary,
739
+ effect: definition.effect,
740
+ scopes: [...definition.scopes],
741
+ method,
742
+ path,
743
+ input: collectOpsCommands({ [localName]: definition.route })[0].input
744
+ });
745
+ routes[name] = { route: definition.route, scopes: definition.scopes };
746
+ }
747
+ }
748
+ descriptors.sort((a, b) => a.id.localeCompare(b.id));
749
+ commands.sort((a, b) => a.name.localeCompare(b.name));
750
+ return { descriptors, commands, routes };
751
+ }
752
+ function secureModuleRoutes(routes, auth, authorize) {
753
+ const secured = {};
754
+ for (const [name, definition] of Object.entries(routes)) {
755
+ assertOpsName(name);
756
+ assertOpsRoute(name, definition.route);
757
+ secured[name] = {
758
+ ...definition.route,
759
+ middlewares: [
760
+ auth,
761
+ authorize(...definition.scopes),
762
+ ...definition.route.middlewares ?? []
763
+ ]
764
+ };
765
+ }
766
+ return secured;
767
+ }
454
768
  function createOpsRouter(routes, options) {
455
769
  if (!options?.auth) {
456
770
  throw new OpsRouterError(
457
771
  "createOpsRouter requires an auth middleware ({ auth: ... }). An ops surface reachable without authentication cannot be created."
458
772
  );
459
773
  }
774
+ const modules = options.modules ?? [];
775
+ if (modules.length > 0 && !options.authorize) {
776
+ throw new OpsRouterError(
777
+ "createOpsRouter requires an authorize scope factory when modules are mounted."
778
+ );
779
+ }
780
+ const appCommands = collectOpsCommands(routes);
781
+ const moduleSurface = compileModules(modules, appCommands);
782
+ const commands = [...appCommands, ...moduleSurface.commands].sort((a, b) => a.name.localeCompare(b.name));
460
783
  const manifest = {
461
784
  manifestVersion: 1,
462
- commands: collectOpsCommands(routes)
785
+ ...moduleSurface.descriptors.length > 0 ? { modules: moduleSurface.descriptors } : {},
786
+ commands
463
787
  };
464
788
  const secured = secureRoutes(routes, options.auth);
789
+ const securedModules = moduleSurface.descriptors.length > 0 ? secureModuleRoutes(moduleSurface.routes, options.auth, options.authorize) : {};
465
790
  const manifestRoute = route.get(OPS_MANIFEST_PATH).use([options.auth]).handler(async () => manifest);
466
791
  return defineRouter({
467
792
  [OPS_MANIFEST_NAME]: manifestRoute,
468
- ...secured
793
+ ...secured,
794
+ ...securedModules
469
795
  });
470
796
  }
471
797
 
472
- // src/ops/ops-route.ts
473
- var OPS_PATH_ROOT = "/_ops";
474
- function toOpsPath(path) {
475
- if (!path.startsWith("/")) {
476
- throw new OpsRouterError(
477
- `Ops route path "${path}" must start with "/". It is appended to "${OPS_PATH_ROOT}", so "${path}" would read as "${OPS_PATH_ROOT}${path}".`
478
- );
479
- }
480
- if (path === "/") {
481
- throw new OpsRouterError(
482
- `Ops route path "/" names no command \u2014 "${OPS_PATH_ROOT}" itself is not a command.`
483
- );
484
- }
485
- return OPS_PATH_ROOT + path;
486
- }
487
- function opsMethod(method) {
488
- return (path) => route[method](toOpsPath(path));
489
- }
490
- var opsRoute = {
491
- get: opsMethod("get"),
492
- post: opsMethod("post"),
493
- put: opsMethod("put"),
494
- patch: opsMethod("patch"),
495
- delete: opsMethod("delete")
496
- };
497
-
498
- export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, OpsRouterError, collectOpsCommands, createOpsRouter, opsRoute };
798
+ export { OPS_MANIFEST_PATH, OPS_PATH_PREFIX, OPS_PATH_ROOT, OpsRouterError, collectOpsCommands, createOpsRouter, defineOpsModule, opsRoute };
499
799
  //# sourceMappingURL=index.js.map
500
800
  //# sourceMappingURL=index.js.map