@jini-ai/core 0.3.1 → 0.4.1

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 (97) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/LICENSE +201 -0
  3. package/README.md +154 -14
  4. package/dist/agent-tools.d.ts +29 -0
  5. package/dist/agent-tools.js +2 -0
  6. package/dist/api-token-auth.d.ts +12 -5
  7. package/dist/api-token-auth.js +7 -7
  8. package/dist/bindings.d.ts +16 -5
  9. package/dist/bindings.js +7 -6
  10. package/dist/{internal.d.ts → composition.d.ts} +9 -9
  11. package/dist/{internal.js → composition.js} +9 -9
  12. package/dist/contribution-registry.d.ts +16 -0
  13. package/dist/contribution-registry.js +12 -0
  14. package/dist/daemon.d.ts +12 -7
  15. package/dist/daemon.js +21 -9
  16. package/dist/gated-mutations/actor-identity.d.ts +20 -0
  17. package/dist/gated-mutations/actor-identity.js +31 -0
  18. package/dist/gated-mutations/gateway.d.ts +115 -0
  19. package/dist/gated-mutations/gateway.js +123 -0
  20. package/dist/gated-mutations/ports.d.ts +27 -0
  21. package/dist/gated-mutations/ports.js +2 -0
  22. package/dist/gated-mutations/token.d.ts +95 -0
  23. package/dist/gated-mutations/token.js +87 -0
  24. package/dist/gated-mutations.d.ts +5 -0
  25. package/dist/gated-mutations.js +5 -0
  26. package/dist/index.d.ts +8 -2
  27. package/dist/index.js +5 -0
  28. package/dist/model-facing-tool-errors.d.ts +37 -0
  29. package/dist/model-facing-tool-errors.js +71 -0
  30. package/dist/naming.d.ts +31 -0
  31. package/dist/naming.js +33 -0
  32. package/dist/origin-validation.d.ts +64 -19
  33. package/dist/origin-validation.js +43 -33
  34. package/dist/pack-lifecycle.d.ts +19 -12
  35. package/dist/pack-lifecycle.js +14 -14
  36. package/dist/pack.d.ts +41 -13
  37. package/dist/pack.js +2 -2
  38. package/dist/primitives/http.d.ts +57 -0
  39. package/dist/primitives/http.js +2 -0
  40. package/dist/primitives/ids.d.ts +8 -0
  41. package/dist/primitives/ids.js +5 -0
  42. package/dist/primitives/index.d.ts +8 -0
  43. package/dist/primitives/index.js +8 -0
  44. package/dist/primitives/json.d.ts +10 -0
  45. package/dist/primitives/json.js +2 -0
  46. package/dist/primitives/logger.d.ts +26 -0
  47. package/dist/primitives/logger.js +17 -0
  48. package/dist/primitives/paths.d.ts +16 -0
  49. package/dist/primitives/paths.js +38 -0
  50. package/dist/primitives/result.d.ts +9 -0
  51. package/dist/primitives/result.js +2 -0
  52. package/dist/primitives/time.d.ts +17 -0
  53. package/dist/primitives/time.js +13 -0
  54. package/dist/redact.d.ts +43 -8
  55. package/dist/redact.js +121 -50
  56. package/dist/registration-kit.d.ts +357 -0
  57. package/dist/registration-kit.js +439 -0
  58. package/dist/text/index.d.ts +3 -0
  59. package/dist/text/index.js +2 -0
  60. package/dist/text/sanitize-untrusted-text.d.ts +22 -0
  61. package/dist/text/sanitize-untrusted-text.js +108 -0
  62. package/dist/token.d.ts +29 -2
  63. package/dist/token.js +22 -2
  64. package/dist/tool-registry.d.ts +32 -16
  65. package/dist/tool-registry.js +11 -11
  66. package/dist/tool-tokens.js +1 -1
  67. package/package.json +56 -11
  68. package/dist/api-token-auth.d.ts.map +0 -1
  69. package/dist/api-token-auth.js.map +0 -1
  70. package/dist/bindings.d.ts.map +0 -1
  71. package/dist/bindings.js.map +0 -1
  72. package/dist/compose.typecheck.d.ts +0 -2
  73. package/dist/compose.typecheck.d.ts.map +0 -1
  74. package/dist/compose.typecheck.js +0 -29
  75. package/dist/compose.typecheck.js.map +0 -1
  76. package/dist/daemon.d.ts.map +0 -1
  77. package/dist/daemon.js.map +0 -1
  78. package/dist/index.d.ts.map +0 -1
  79. package/dist/index.js.map +0 -1
  80. package/dist/internal.d.ts.map +0 -1
  81. package/dist/internal.js.map +0 -1
  82. package/dist/origin-validation.d.ts.map +0 -1
  83. package/dist/origin-validation.js.map +0 -1
  84. package/dist/pack-lifecycle.d.ts.map +0 -1
  85. package/dist/pack-lifecycle.js.map +0 -1
  86. package/dist/pack.d.ts.map +0 -1
  87. package/dist/pack.js.map +0 -1
  88. package/dist/principal.d.ts.map +0 -1
  89. package/dist/principal.js.map +0 -1
  90. package/dist/redact.d.ts.map +0 -1
  91. package/dist/redact.js.map +0 -1
  92. package/dist/token.d.ts.map +0 -1
  93. package/dist/token.js.map +0 -1
  94. package/dist/tool-registry.d.ts.map +0 -1
  95. package/dist/tool-registry.js.map +0 -1
  96. package/dist/tool-tokens.d.ts.map +0 -1
  97. package/dist/tool-tokens.js.map +0 -1
@@ -4,27 +4,27 @@
4
4
  * Whether a bearer-style API token gate should be enforced, and what the
5
5
  * expected token value is. Generalized from an upstream flat daemon module:
6
6
  * the origin hardcoded its host product's env-var names, now fields on
7
- * {@link ApiTokenAuthEnvConfig} — see `source-map.md` for the exact mapping.
7
+ * {@link ApiTokenAuthEnvConfig} — see `archived provenance ledger` for the exact mapping.
8
8
  */
9
9
  /**
10
10
  * Interprets `value` as a truthy flag: `'1'`, `'true'`, `'yes'`, or `'on'`
11
11
  * (case-insensitive, trimmed). Anything else, including `undefined`, is
12
12
  * falsy.
13
13
  */
14
- export function isTruthyEnvFlag(value) {
14
+ export function isTruthyEnvFlag({ value }) {
15
15
  const normalized = String(value || '').trim().toLowerCase();
16
16
  return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
17
17
  }
18
18
  /** Whether the API auth gate has been explicitly force-disabled. */
19
- export function isApiAuthDisabled(config, env = process.env) {
20
- return isTruthyEnvFlag(env[config.disableEnvVar]);
19
+ export function isApiAuthDisabled({ config, env }) {
20
+ return isTruthyEnvFlag({ value: env[config.disableEnvVar] });
21
21
  }
22
22
  /** The configured expected token value, trimmed; `''` when unset. */
23
- export function apiTokenFromEnv(config, env = process.env) {
23
+ export function apiTokenFromEnv({ config, env }) {
24
24
  return (env[config.tokenEnvVar] ?? '').trim();
25
25
  }
26
26
  /** Whether the token gate should actually run: a token is configured and it hasn't been disabled. */
27
- export function isApiTokenMiddlewareEnabled(config, env = process.env) {
28
- return apiTokenFromEnv(config, env).length > 0 && !isApiAuthDisabled(config, env);
27
+ export function isApiTokenMiddlewareEnabled({ config, env }) {
28
+ return apiTokenFromEnv({ config, env }).length > 0 && !isApiAuthDisabled({ config, env });
29
29
  }
30
30
  //# sourceMappingURL=api-token-auth.js.map
@@ -5,14 +5,25 @@ import type { ManyToken, Token } from './token.js';
5
5
  * `Exclude<RequiredTokenIds<Packs>, BoundIds>` at compile time (see daemon.ts).
6
6
  */
7
7
  export declare class Bindings<BoundIds extends string = never> {
8
+ constructor(_required: Record<string, never>);
8
9
  private readonly one;
9
10
  private readonly many;
10
- bind<T, Id extends string>(t: Token<T, Id>, impl: T): Bindings<BoundIds | Id>;
11
- bindMany<T, Id extends string>(t: ManyToken<T, Id>, impl: T): Bindings<BoundIds | Id>;
11
+ bind<T, Id extends string>({ token: t, impl }: {
12
+ token: Token<T, Id>;
13
+ impl: T;
14
+ }): Bindings<BoundIds | Id>;
15
+ bindMany<T, Id extends string>({ token: t, impl }: {
16
+ token: ManyToken<T, Id>;
17
+ impl: T;
18
+ }): Bindings<BoundIds | Id>;
12
19
  /** @internal */
13
- resolveOne<T>(t: Token<T, string>): T;
20
+ resolveOne<T>({ token: t }: {
21
+ token: Token<T, string>;
22
+ }): T;
14
23
  /** @internal */
15
- resolveMany<T>(t: ManyToken<T, string>): T[];
24
+ resolveMany<T>({ token: t }: {
25
+ token: ManyToken<T, string>;
26
+ }): T[];
16
27
  }
17
- export declare function bindings(): Bindings<never>;
28
+ export declare function bindings(required: Record<string, never>): Bindings<never>;
18
29
  //# sourceMappingURL=bindings.d.ts.map
package/dist/bindings.js CHANGED
@@ -4,23 +4,24 @@
4
4
  * `Exclude<RequiredTokenIds<Packs>, BoundIds>` at compile time (see daemon.ts).
5
5
  */
6
6
  export class Bindings {
7
+ constructor(_required) { }
7
8
  one = new Map();
8
9
  many = new Map();
9
- bind(t, impl) {
10
+ bind({ token: t, impl }) {
10
11
  if (this.one.has(t.id)) {
11
12
  throw new Error(`duplicate binding: ${t.id} is already bound (a singleton token accepts exactly one binding)`);
12
13
  }
13
14
  this.one.set(t.id, { value: impl, version: t.version });
14
15
  return this;
15
16
  }
16
- bindMany(t, impl) {
17
+ bindMany({ token: t, impl }) {
17
18
  const list = this.many.get(t.id) ?? [];
18
19
  list.push(impl);
19
20
  this.many.set(t.id, list);
20
21
  return this;
21
22
  }
22
23
  /** @internal */
23
- resolveOne(t) {
24
+ resolveOne({ token: t }) {
24
25
  const entry = this.one.get(t.id);
25
26
  if (!entry) {
26
27
  throw new Error(`missing binding: ${t.id}`);
@@ -31,11 +32,11 @@ export class Bindings {
31
32
  return entry.value;
32
33
  }
33
34
  /** @internal */
34
- resolveMany(t) {
35
+ resolveMany({ token: t }) {
35
36
  return (this.many.get(t.id) ?? []);
36
37
  }
37
38
  }
38
- export function bindings() {
39
- return new Bindings();
39
+ export function bindings(required) {
40
+ return new Bindings(required);
40
41
  }
41
42
  //# sourceMappingURL=bindings.js.map
@@ -1,9 +1,9 @@
1
1
  /**
2
- * @module @jini-ai/core/internal
2
+ * @module @jini-ai/core/composition
3
3
  *
4
- * Package-internal entry point — NOT part of `@jini-ai/core`'s public
5
- * contract. Two unrelated escape hatches live here, each for its own
6
- * single intended consumer:
4
+ * Composition boundary for daemon assembly and authorized tool invocation.
5
+ * These capabilities stay off the root entry point; each has its own
6
+ * intended consumer:
7
7
  *
8
8
  * - {@link authorizeToolInvocation} so `@jini-ai/daemon`'s `ToolExecutor` can
9
9
  * resolve a registered tool and run its authorization gate — the tool's
@@ -24,18 +24,18 @@
24
24
  *
25
25
  * **Correction (2026-07-19 hardening pass):** a package.json `exports` map
26
26
  * subpath is NOT a language-level or per-consumer access modifier — Node's
27
- * module resolution will happily resolve `@jini-ai/core/internal` for *any*
27
+ * module resolution will happily resolve `@jini-ai/core/composition` for *any*
28
28
  * package that depends on `@jini-ai/core`, not just the intended consumer.
29
29
  * Earlier revisions of this doc comment claimed this boundary was
30
30
  * "enforced" by the exports map; that was inaccurate and left the leak
31
31
  * undetected. The actual enforcement is `scripts/check-engine-boundaries.ts`'s
32
- * rule forbidding any *value* import from `@jini-ai/core/internal` outside
32
+ * R6-internal-leak rule forbidding any *value* import from `@jini-ai/core/composition` outside
33
33
  * `packages/daemon/**` (type-only imports of the DI-token-derivation types
34
- * remain unrestricted, since they carry no runtime capability) — that guard
35
- * is wired in, but is a second, independent layer on top of the 2026-07-29
34
+ * remain unrestricted, since they carry no runtime capability). R6 checks this
35
+ * exact composition specifier as a second, independent layer on top of the 2026-07-29
36
36
  * structural fix above, not a substitute for it.
37
37
  */
38
38
  export { authorizeToolInvocation } from './tool-registry.js';
39
39
  export type { ToolAuthorizationDelegate, ToolInvocationAuthorization } from './tool-registry.js';
40
40
  export type { AnyPack, MissingTokenIds, RequiredTokenIds } from './daemon.js';
41
- //# sourceMappingURL=internal.d.ts.map
41
+ //# sourceMappingURL=composition.d.ts.map
@@ -1,9 +1,9 @@
1
1
  /**
2
- * @module @jini-ai/core/internal
2
+ * @module @jini-ai/core/composition
3
3
  *
4
- * Package-internal entry point — NOT part of `@jini-ai/core`'s public
5
- * contract. Two unrelated escape hatches live here, each for its own
6
- * single intended consumer:
4
+ * Composition boundary for daemon assembly and authorized tool invocation.
5
+ * These capabilities stay off the root entry point; each has its own
6
+ * intended consumer:
7
7
  *
8
8
  * - {@link authorizeToolInvocation} so `@jini-ai/daemon`'s `ToolExecutor` can
9
9
  * resolve a registered tool and run its authorization gate — the tool's
@@ -24,16 +24,16 @@
24
24
  *
25
25
  * **Correction (2026-07-19 hardening pass):** a package.json `exports` map
26
26
  * subpath is NOT a language-level or per-consumer access modifier — Node's
27
- * module resolution will happily resolve `@jini-ai/core/internal` for *any*
27
+ * module resolution will happily resolve `@jini-ai/core/composition` for *any*
28
28
  * package that depends on `@jini-ai/core`, not just the intended consumer.
29
29
  * Earlier revisions of this doc comment claimed this boundary was
30
30
  * "enforced" by the exports map; that was inaccurate and left the leak
31
31
  * undetected. The actual enforcement is `scripts/check-engine-boundaries.ts`'s
32
- * rule forbidding any *value* import from `@jini-ai/core/internal` outside
32
+ * R6-internal-leak rule forbidding any *value* import from `@jini-ai/core/composition` outside
33
33
  * `packages/daemon/**` (type-only imports of the DI-token-derivation types
34
- * remain unrestricted, since they carry no runtime capability) — that guard
35
- * is wired in, but is a second, independent layer on top of the 2026-07-29
34
+ * remain unrestricted, since they carry no runtime capability). R6 checks this
35
+ * exact composition specifier as a second, independent layer on top of the 2026-07-29
36
36
  * structural fix above, not a substitute for it.
37
37
  */
38
38
  export { authorizeToolInvocation } from './tool-registry.js';
39
- //# sourceMappingURL=internal.js.map
39
+ //# sourceMappingURL=composition.js.map
@@ -0,0 +1,16 @@
1
+ /** Replace-by-key registry for host-owned contributions; no singleton or execution surface. */
2
+ export interface ContributionRegistry<TContribution> {
3
+ register(required: {
4
+ contribution: TContribution;
5
+ }): void;
6
+ /** Detached array snapshot; contribution values retain their identity. */
7
+ list(_required: Record<string, never>): readonly TContribution[];
8
+ clear(_required: Record<string, never>): void;
9
+ }
10
+ /** O(1) register/clear, O(n) list and O(n) retained entries. Replacement preserves position. */
11
+ export declare function createContributionRegistry<TContribution, TKey>(required: {
12
+ keyOf: (required: {
13
+ contribution: TContribution;
14
+ }) => TKey;
15
+ }): ContributionRegistry<TContribution>;
16
+ //# sourceMappingURL=contribution-registry.d.ts.map
@@ -0,0 +1,12 @@
1
+ /** O(1) register/clear, O(n) list and O(n) retained entries. Replacement preserves position. */
2
+ export function createContributionRegistry(required) {
3
+ const contributions = new Map();
4
+ return {
5
+ register({ contribution }) {
6
+ contributions.set(required.keyOf({ contribution }), contribution);
7
+ },
8
+ list(_required) { return [...contributions.values()]; },
9
+ clear(_required) { contributions.clear(); },
10
+ };
11
+ }
12
+ //# sourceMappingURL=contribution-registry.js.map
package/dist/daemon.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { Bindings } from './bindings.js';
2
2
  import type { Pack } from './pack.js';
3
3
  /**
4
- * Exported (not just used locally) so `./internal.js` can re-derive `createDaemon`'s exact
4
+ * Exported (not just used locally) so `./composition.js` can re-derive `createDaemon`'s exact
5
5
  * compile-time "missing binding" gate for a wrapper composition function (today:
6
6
  * `@jini-ai/server`'s `createLocalNodeDaemon`) without duplicating the type-level logic. Not
7
- * re-exported from `index.ts` — see `./internal.ts`'s module doc for why this stays off the
7
+ * re-exported from `index.ts` — see `./composition.ts`'s module doc for why this stays off the
8
8
  * public surface (same boundary pattern as `tool-registry.ts`'s `authorizeToolInvocation`).
9
9
  */
10
10
  export type AnyPack = Pack<any, any, string>;
@@ -21,6 +21,9 @@ export interface Daemon<Packs extends readonly AnyPack[] = readonly AnyPack[]> {
21
21
  export interface DaemonConfig<Packs extends readonly AnyPack[], BoundIds extends string> {
22
22
  packs: Packs;
23
23
  bindings: Bindings<BoundIds>;
24
+ }
25
+ /** Optional transport metadata, separate from required packs and bindings. */
26
+ export interface DaemonOptions {
24
27
  transports?: readonly unknown[];
25
28
  }
26
29
  /**
@@ -28,13 +31,15 @@ export interface DaemonConfig<Packs extends readonly AnyPack[], BoundIds extends
28
31
  * declares a dep whose id isn't in `BoundIds`, the config parameter widens to
29
32
  * require an (unsatisfiable) `__missingBindings` property carrying the exact
30
33
  * missing-id union, so the call site fails to typecheck with that union
31
- * visible in the error. Runtime: `bindings.bind()` already rejects duplicate
32
- * singletons and version mismatches at bind time; resolving here (eagerly,
33
- * per pack, at compose time) surfaces "missing binding" even if the
34
- * compile-time gate was bypassed with `any`.
34
+ * visible in the error. Runtime: `bindings.bind()` rejects duplicate
35
+ * singletons at bind time; singleton resolution checks presence and version.
36
+ * Resolve every declared dependency before constructing any services, so an
37
+ * unused singleton still surfaces "missing binding" or a version mismatch
38
+ * even if the compile-time gate was bypassed with `any`. Many-token resolution
39
+ * preserves its existing empty-list behavior and does not check versions.
35
40
  */
36
41
  export declare function createDaemon<const Packs extends readonly AnyPack[], BoundIds extends string>(config: DaemonConfig<Packs, BoundIds> & (MissingTokenIds<Packs, BoundIds> extends never ? unknown : {
37
42
  readonly __missingBindings: MissingTokenIds<Packs, BoundIds>;
38
- })): Daemon<Packs>;
43
+ }), _optional?: DaemonOptions): Daemon<Packs>;
39
44
  export {};
40
45
  //# sourceMappingURL=daemon.d.ts.map
package/dist/daemon.js CHANGED
@@ -6,13 +6,13 @@ function makeContainer(pack, bound) {
6
6
  }
7
7
  };
8
8
  return {
9
- get(t) {
9
+ get({ token: t }) {
10
10
  guard(t.id);
11
- return bound.resolveOne(t);
11
+ return bound.resolveOne({ token: t });
12
12
  },
13
- getMany(t) {
13
+ getMany({ token: t }) {
14
14
  guard(t.id);
15
- return bound.resolveMany(t);
15
+ return bound.resolveMany({ token: t });
16
16
  },
17
17
  };
18
18
  }
@@ -21,12 +21,24 @@ function makeContainer(pack, bound) {
21
21
  * declares a dep whose id isn't in `BoundIds`, the config parameter widens to
22
22
  * require an (unsatisfiable) `__missingBindings` property carrying the exact
23
23
  * missing-id union, so the call site fails to typecheck with that union
24
- * visible in the error. Runtime: `bindings.bind()` already rejects duplicate
25
- * singletons and version mismatches at bind time; resolving here (eagerly,
26
- * per pack, at compose time) surfaces "missing binding" even if the
27
- * compile-time gate was bypassed with `any`.
24
+ * visible in the error. Runtime: `bindings.bind()` rejects duplicate
25
+ * singletons at bind time; singleton resolution checks presence and version.
26
+ * Resolve every declared dependency before constructing any services, so an
27
+ * unused singleton still surfaces "missing binding" or a version mismatch
28
+ * even if the compile-time gate was bypassed with `any`. Many-token resolution
29
+ * preserves its existing empty-list behavior and does not check versions.
28
30
  */
29
- export function createDaemon(config) {
31
+ export function createDaemon(config, _optional = {}) {
32
+ // Validate the whole composition before service factories acquire resources;
33
+ // checking one pack at a time could leave earlier packs initialized on failure.
34
+ for (const pack of config.packs) {
35
+ for (const dependency of pack.deps) {
36
+ if (dependency.cardinality === 'one')
37
+ config.bindings.resolveOne({ token: dependency });
38
+ else
39
+ config.bindings.resolveMany({ token: dependency });
40
+ }
41
+ }
30
42
  const services = {};
31
43
  for (const pack of config.packs) {
32
44
  const container = makeContainer(pack, config.bindings);
@@ -0,0 +1,20 @@
1
+ /** Pure composite actor references; reject references across workspace boundaries. O(1). */
2
+ export declare class WorkspaceMismatchError extends Error {
3
+ constructor(required: {
4
+ message: string;
5
+ }, optional?: ErrorOptions);
6
+ }
7
+ export declare function appendActorReference(required: {
8
+ referencingRowWorkspaceId: string;
9
+ actorWorkspaceId: string;
10
+ actorId: string;
11
+ }, optional?: {
12
+ delegatedByWorkspaceId?: string | null;
13
+ delegatedById?: string | null;
14
+ }): {
15
+ actorWorkspaceId: string;
16
+ actorId: string;
17
+ delegatedByWorkspaceId: string | null;
18
+ delegatedById: string | null;
19
+ };
20
+ //# sourceMappingURL=actor-identity.d.ts.map
@@ -0,0 +1,31 @@
1
+ /** Pure composite actor references; reject references across workspace boundaries. O(1). */
2
+ export class WorkspaceMismatchError extends Error {
3
+ constructor(required, optional = {}) {
4
+ super(required.message, optional);
5
+ }
6
+ }
7
+ // Composite (workspace, actor) references retain the tenant boundary even when an agent or
8
+ // API key acts for a delegating user. Reject mismatches before a caller persists the row.
9
+ // This helper performs no I/O or foreign-key checks: dependent domains own read-time orphan
10
+ // tolerance and reconciliation, rather than making identity population depend on storage.
11
+ export function appendActorReference(required, optional = {}) {
12
+ const { referencingRowWorkspaceId, actorWorkspaceId, actorId } = required;
13
+ const { delegatedByWorkspaceId, delegatedById } = optional;
14
+ if (actorWorkspaceId !== referencingRowWorkspaceId) {
15
+ throw new WorkspaceMismatchError({
16
+ message: `actor workspace '${actorWorkspaceId}' does not match referencing row workspace '${referencingRowWorkspaceId}'`
17
+ });
18
+ }
19
+ if (delegatedByWorkspaceId != null && delegatedByWorkspaceId !== referencingRowWorkspaceId) {
20
+ throw new WorkspaceMismatchError({
21
+ message: `delegator workspace '${delegatedByWorkspaceId}' does not match referencing row workspace '${referencingRowWorkspaceId}'`
22
+ });
23
+ }
24
+ return {
25
+ actorWorkspaceId,
26
+ actorId,
27
+ delegatedByWorkspaceId: delegatedByWorkspaceId ?? null,
28
+ delegatedById: delegatedById ?? null,
29
+ };
30
+ }
31
+ //# sourceMappingURL=actor-identity.js.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Transport-neutral plan -> confirm -> execute approval ceremony.
3
+ * Ordering: authorize, token state, fresh actor identity, scope/hash, atomic redeem, mutation.
4
+ * Mutation receives the verified plan and must not independently rederive its write set.
5
+ * O(1) local work plus injected port costs; no lock or transactional rollback is implied.
6
+ */
7
+ import { type Clock, type IdGenerator } from "../primitives/index.js";
8
+ import type { AuthorizeFn, InstanceAuthorizeFn, PrincipalKind } from "./ports.js";
9
+ import { type ConfirmationTokenRecord, type TokenStorePort } from "./token.js";
10
+ export interface GatedMutationHooks<TDetails, TResult> {
11
+ domain: string;
12
+ readPermission: string;
13
+ mutatePermission: string;
14
+ /** Workspace scope is the authorization target; for instance scope this is only an opaque
15
+ * audit label. A workspace grant must never stand in for authority over every workspace. */
16
+ scopeId: string;
17
+ /** Omission preserves workspace authorization; instance scope uses its own injected evaluator. */
18
+ scopeKind?: "workspace" | "instance";
19
+ computePlan(required: Record<string, never>): Promise<{
20
+ planHash: string;
21
+ details: TDetails;
22
+ }>;
23
+ /**
24
+ * Apply the verified plan, never recompute it: token redemption and any intervening mutation
25
+ * work separate verification from application, and this gateway takes no operation lock.
26
+ * Re-deriving there can apply a different write set from the one the operator approved.
27
+ * A mutation without plan-dependent work can ignore this argument.
28
+ */
29
+ executeMutation(verified: {
30
+ planHash: string;
31
+ details: TDetails;
32
+ }): Promise<TResult>;
33
+ /** Resolve fresh at execution: the user itself, an agent's current delegator, or an API key's
34
+ * owning user. Confirm-time identity can become stale before redemption. */
35
+ resolveActorClassIdentity(params: {
36
+ principalId: string;
37
+ principalKind: PrincipalKind;
38
+ }): Promise<string | null>;
39
+ }
40
+ export interface GatewayDeps {
41
+ /** Required token policy: caller supplies the complete opaque token and TTL. */
42
+ generateToken: (required: Record<string, never>) => string;
43
+ ttlSeconds: number;
44
+ clock: Clock;
45
+ idGen: IdGenerator;
46
+ authorize: AuthorizeFn;
47
+ /** Missing instance authorization must deny, never fall back to a workspace-scoped grant. */
48
+ authorizeInstance?: InstanceAuthorizeFn;
49
+ tokens: TokenStorePort;
50
+ }
51
+ export interface GatewayPlan {
52
+ domain: string;
53
+ planId: string;
54
+ planHash: string;
55
+ details: unknown;
56
+ }
57
+ export declare class ForbiddenError extends Error {
58
+ readonly reasonCode: string;
59
+ constructor(required: {
60
+ message: string;
61
+ reasonCode: string;
62
+ }, optional?: ErrorOptions);
63
+ }
64
+ export declare class PlanStaleError extends Error {
65
+ constructor(required: {
66
+ message: string;
67
+ }, optional?: ErrorOptions);
68
+ }
69
+ export declare class UnauthenticatedError extends Error {
70
+ constructor(required: {
71
+ message: string;
72
+ }, optional?: ErrorOptions);
73
+ }
74
+ /**
75
+ * Share this scope decision with route authorization before operation-lock acquisition. A route
76
+ * that hardcodes workspace authorization can briefly acquire a lock with insufficient instance
77
+ * authority before the deeper gate refuses it. Missing instance wiring therefore denies outright.
78
+ */
79
+ export declare function authorizeForHooks(required: {
80
+ deps: GatewayDeps;
81
+ hooks: GatedMutationHooks<unknown, unknown>;
82
+ principalId: string;
83
+ permission: string;
84
+ }): Promise<{
85
+ allowed: boolean;
86
+ reason: string;
87
+ }>;
88
+ export declare function plan(required: {
89
+ deps: GatewayDeps;
90
+ principalId: string;
91
+ principalKind: PrincipalKind;
92
+ hooks: GatedMutationHooks<unknown, unknown>;
93
+ }, _optional?: Record<string, never>): Promise<GatewayPlan>;
94
+ export declare function confirm(required: {
95
+ deps: GatewayDeps;
96
+ principalId: string;
97
+ principalKind: PrincipalKind;
98
+ hooks: GatedMutationHooks<unknown, unknown>;
99
+ planId: string;
100
+ planHash: string;
101
+ }, _optional?: Record<string, never>): Promise<ConfirmationTokenRecord>;
102
+ /**
103
+ * Rejection precedence is binding when failures co-occur: fresh authorization, token state,
104
+ * fresh actor identity, then plan validity. Expired/redeemed tokens win over identity mismatch;
105
+ * identity mismatch wins over a simultaneously stale plan. Typed errors keep these outcomes
106
+ * distinct for callers. Only atomic redemption permits the domain mutation.
107
+ */
108
+ export declare function execute<TResult>(required: {
109
+ deps: GatewayDeps;
110
+ principalId: string;
111
+ principalKind: PrincipalKind;
112
+ hooks: GatedMutationHooks<unknown, TResult>;
113
+ confirmationToken: string;
114
+ }, _optional?: Record<string, never>): Promise<TResult>;
115
+ //# sourceMappingURL=gateway.d.ts.map
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Transport-neutral plan -> confirm -> execute approval ceremony.
3
+ * Ordering: authorize, token state, fresh actor identity, scope/hash, atomic redeem, mutation.
4
+ * Mutation receives the verified plan and must not independently rederive its write set.
5
+ * O(1) local work plus injected port costs; no lock or transactional rollback is implied.
6
+ */
7
+ import { nowIso } from "../primitives/index.js";
8
+ import { TokenAlreadyRedeemedError, TokenExpiredError, isRedeemable, mintToken, } from "./token.js";
9
+ export class ForbiddenError extends Error {
10
+ reasonCode;
11
+ constructor(required, optional = {}) {
12
+ super(required.message, optional);
13
+ this.reasonCode = required.reasonCode;
14
+ }
15
+ }
16
+ export class PlanStaleError extends Error {
17
+ constructor(required, optional = {}) {
18
+ super(required.message, optional);
19
+ }
20
+ }
21
+ // Reserved for a host that cannot establish caller identity before entering the ceremony;
22
+ // the gateway itself does not currently raise this error.
23
+ export class UnauthenticatedError extends Error {
24
+ constructor(required, optional = {}) {
25
+ super(required.message, optional);
26
+ }
27
+ }
28
+ /**
29
+ * Share this scope decision with route authorization before operation-lock acquisition. A route
30
+ * that hardcodes workspace authorization can briefly acquire a lock with insufficient instance
31
+ * authority before the deeper gate refuses it. Missing instance wiring therefore denies outright.
32
+ */
33
+ export async function authorizeForHooks(required) {
34
+ const { deps, hooks, principalId, permission } = required;
35
+ if (hooks.scopeKind !== "instance") {
36
+ return deps.authorize({ principalId, permission, workspaceId: hooks.scopeId });
37
+ }
38
+ if (!deps.authorizeInstance) {
39
+ return { allowed: false, reason: "INSTANCE_AUTHORIZATION_NOT_CONFIGURED" };
40
+ }
41
+ return deps.authorizeInstance({ principalId, permission });
42
+ }
43
+ // Planning is read-only: check read permission and derive details, never invoke executeMutation.
44
+ export async function plan(required, _optional = {}) {
45
+ const { deps, principalId, hooks } = required;
46
+ const authResult = await authorizeForHooks({ deps, hooks, principalId, permission: hooks.readPermission });
47
+ if (!authResult.allowed) {
48
+ throw new ForbiddenError({ message: `principal '${principalId}' is not authorized for '${hooks.readPermission}' (${authResult.reason})`, reasonCode: "NOT_AUTHORIZED" });
49
+ }
50
+ const { planHash, details } = await hooks.computePlan({});
51
+ return { domain: hooks.domain, planId: deps.idGen.newId(), planHash, details };
52
+ }
53
+ // Confirmation is a human/API-key act: an agent may redeem its delegator's approval later,
54
+ // but may never mint its own confirmation, regardless of its mutation permissions.
55
+ export async function confirm(required, _optional = {}) {
56
+ const { deps, principalId, principalKind, hooks, planId, planHash } = required;
57
+ if (principalKind === "agent") {
58
+ throw new ForbiddenError({ message: `agent principals may not confirm a gated mutation`, reasonCode: "AGENT_CANNOT_CONFIRM" });
59
+ }
60
+ const authResult = await authorizeForHooks({ deps, hooks, principalId, permission: hooks.mutatePermission });
61
+ if (!authResult.allowed) {
62
+ throw new ForbiddenError({ message: `principal '${principalId}' is not authorized for '${hooks.mutatePermission}' (${authResult.reason})`, reasonCode: "NOT_AUTHORIZED" });
63
+ }
64
+ const token = mintToken({
65
+ planId,
66
+ planHash,
67
+ scopeId: hooks.scopeId,
68
+ confirmerPrincipalId: principalId,
69
+ now: nowIso({ clock: deps.clock }),
70
+ generateToken: deps.generateToken,
71
+ ttlSeconds: deps.ttlSeconds,
72
+ });
73
+ await deps.tokens.save({ record: token });
74
+ return token;
75
+ }
76
+ /**
77
+ * Rejection precedence is binding when failures co-occur: fresh authorization, token state,
78
+ * fresh actor identity, then plan validity. Expired/redeemed tokens win over identity mismatch;
79
+ * identity mismatch wins over a simultaneously stale plan. Typed errors keep these outcomes
80
+ * distinct for callers. Only atomic redemption permits the domain mutation.
81
+ */
82
+ export async function execute(required, _optional = {}) {
83
+ // Reevaluate authorization here; confirm-time permission is not authority to execute later.
84
+ const { deps, principalId, principalKind, hooks, confirmationToken } = required;
85
+ const authResult = await authorizeForHooks({ deps, hooks, principalId, permission: hooks.mutatePermission });
86
+ if (!authResult.allowed) {
87
+ throw new ForbiddenError({ message: `principal '${principalId}' is not authorized for '${hooks.mutatePermission}' (${authResult.reason})`, reasonCode: "NOT_AUTHORIZED" });
88
+ }
89
+ const record = await deps.tokens.findByToken({ token: confirmationToken });
90
+ if (!record) {
91
+ throw new TokenExpiredError({ message: `confirmation token was not found` });
92
+ }
93
+ if (!isRedeemable({ record, now: nowIso({ clock: deps.clock }) })) {
94
+ if (record.status === "redeemed") {
95
+ throw new TokenAlreadyRedeemedError({ message: `confirmation token has already been redeemed` });
96
+ }
97
+ throw new TokenExpiredError({ message: `confirmation token has expired` });
98
+ }
99
+ const resolvedIdentity = await hooks.resolveActorClassIdentity({ principalId, principalKind });
100
+ if (resolvedIdentity !== record.confirmerPrincipalId) {
101
+ throw new ForbiddenError({ message: `principal '${principalId}' (kind '${principalKind}') is not the confirmer of this token`, reasonCode: "ACTOR_CLASS_MISMATCH" });
102
+ }
103
+ if (record.scopeId !== hooks.scopeId) {
104
+ throw new ForbiddenError({ message: `confirmation token belongs to a different scope`, reasonCode: "SCOPE_MISMATCH" });
105
+ }
106
+ // Retain these verified details through redemption so application cannot choose a new plan.
107
+ const { planHash, details } = await hooks.computePlan({});
108
+ if (planHash !== record.planHash) {
109
+ throw new PlanStaleError({
110
+ message: `the plan backing this confirmation has changed since it was confirmed; re-plan and re-confirm`
111
+ });
112
+ }
113
+ // Concurrent executions can pass every earlier check; atomic redemption makes the loser fail
114
+ // before its domain mutation, preventing a second application of the same approval.
115
+ const { redeemed, record: latestRecord } = await deps.tokens.tryRedeem({ token: confirmationToken, now: nowIso({ clock: deps.clock }) });
116
+ if (!redeemed) {
117
+ if (!latestRecord || latestRecord.status !== "redeemed")
118
+ throw new TokenExpiredError({ message: `confirmation token has expired` });
119
+ throw new TokenAlreadyRedeemedError({ message: `confirmation token has already been redeemed` });
120
+ }
121
+ return hooks.executeMutation({ planHash, details });
122
+ }
123
+ //# sourceMappingURL=gateway.js.map
@@ -0,0 +1,27 @@
1
+ /** Authorization ports; custom actor kinds use the injected actor-identity resolver. */
2
+ export type PrincipalKind = "user" | "agent" | "api_key" | (string & {});
3
+ /** The host binds its identity evaluator as a closure; core must not import the identity feature. */
4
+ export type AuthorizeFn = (required: {
5
+ principalId: string;
6
+ permission: string;
7
+ workspaceId: string;
8
+ }, optional?: {
9
+ entityType?: string;
10
+ entityId?: string;
11
+ }) => Promise<{
12
+ allowed: boolean;
13
+ reason: string;
14
+ }>;
15
+ /**
16
+ * Instance-wide mutations need a separate evaluator, without a workspace id. Using a sentinel
17
+ * workspace either finds no principal or lets one tenant's admin approve a cross-tenant mutation.
18
+ * A workspace authorization result must never stand in for authority over the whole instance.
19
+ */
20
+ export type InstanceAuthorizeFn = (params: {
21
+ principalId: string;
22
+ permission: string;
23
+ }) => Promise<{
24
+ allowed: boolean;
25
+ reason: string;
26
+ }>;
27
+ //# sourceMappingURL=ports.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ports.js.map