every-plugin 3.0.0-rc.15 → 3.0.0-rc.17

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 (75) hide show
  1. package/dist/build/artifact-names.cjs +8 -5
  2. package/dist/build/artifact-names.cjs.map +1 -1
  3. package/dist/build/artifact-names.d.cts +7 -5
  4. package/dist/build/artifact-names.d.cts.map +1 -1
  5. package/dist/build/artifact-names.d.mts +7 -5
  6. package/dist/build/artifact-names.d.mts.map +1 -1
  7. package/dist/build/artifact-names.mjs +8 -5
  8. package/dist/build/artifact-names.mjs.map +1 -1
  9. package/dist/build/ui/stubs.cjs +2 -7
  10. package/dist/build/ui/stubs.cjs.map +1 -1
  11. package/dist/build/ui/stubs.d.cts.map +1 -1
  12. package/dist/build/ui/stubs.d.mts.map +1 -1
  13. package/dist/build/ui/stubs.mjs +2 -7
  14. package/dist/build/ui/stubs.mjs.map +1 -1
  15. package/dist/client.cjs +15 -63
  16. package/dist/client.cjs.map +1 -1
  17. package/dist/client.d.cts +12 -11
  18. package/dist/client.d.cts.map +1 -1
  19. package/dist/client.d.mts +12 -11
  20. package/dist/client.d.mts.map +1 -1
  21. package/dist/client.mjs +16 -64
  22. package/dist/client.mjs.map +1 -1
  23. package/dist/errors.cjs +11 -1
  24. package/dist/errors.cjs.map +1 -1
  25. package/dist/errors.d.cts +15 -1
  26. package/dist/errors.d.cts.map +1 -1
  27. package/dist/errors.d.mts +15 -1
  28. package/dist/errors.d.mts.map +1 -1
  29. package/dist/errors.mjs +11 -2
  30. package/dist/errors.mjs.map +1 -1
  31. package/dist/index.cjs +1 -0
  32. package/dist/index.d.cts +4 -4
  33. package/dist/index.d.mts +4 -4
  34. package/dist/index.mjs +2 -2
  35. package/dist/runtime/mf-config.cjs +1 -1
  36. package/dist/runtime/mf-config.mjs +1 -1
  37. package/dist/ui/manifest/construct.cjs +13 -38
  38. package/dist/ui/manifest/construct.cjs.map +1 -1
  39. package/dist/ui/manifest/construct.d.cts +4 -13
  40. package/dist/ui/manifest/construct.d.cts.map +1 -1
  41. package/dist/ui/manifest/construct.d.mts +4 -13
  42. package/dist/ui/manifest/construct.d.mts.map +1 -1
  43. package/dist/ui/manifest/construct.mjs +14 -39
  44. package/dist/ui/manifest/construct.mjs.map +1 -1
  45. package/dist/ui/manifest/descriptor.cjs +1 -1
  46. package/dist/ui/manifest/descriptor.mjs +1 -1
  47. package/dist/ui/manifest/index.d.cts +3 -3
  48. package/dist/ui/manifest/index.d.mts +3 -3
  49. package/dist/ui/manifest/mount-registry.cjs +6 -13
  50. package/dist/ui/manifest/mount-registry.cjs.map +1 -1
  51. package/dist/ui/manifest/mount-registry.d.cts +29 -36
  52. package/dist/ui/manifest/mount-registry.d.cts.map +1 -1
  53. package/dist/ui/manifest/mount-registry.d.mts +29 -36
  54. package/dist/ui/manifest/mount-registry.d.mts.map +1 -1
  55. package/dist/ui/manifest/mount-registry.mjs +6 -13
  56. package/dist/ui/manifest/mount-registry.mjs.map +1 -1
  57. package/dist/version-manifest.cjs +70 -5
  58. package/dist/version-manifest.cjs.map +1 -1
  59. package/dist/version-manifest.d.cts +41 -1
  60. package/dist/version-manifest.d.cts.map +1 -1
  61. package/dist/version-manifest.d.mts +41 -1
  62. package/dist/version-manifest.d.mts.map +1 -1
  63. package/dist/version-manifest.mjs +66 -6
  64. package/dist/version-manifest.mjs.map +1 -1
  65. package/package.json +1 -1
  66. package/skills/plugin-client/SKILL.md +3 -3
  67. package/src/build/artifact-names.ts +8 -5
  68. package/src/build/ui/stubs.ts +2 -7
  69. package/src/client.ts +14 -111
  70. package/src/errors.ts +10 -0
  71. package/src/index.ts +2 -0
  72. package/src/ui/manifest/construct.ts +20 -65
  73. package/src/ui/manifest/index.ts +0 -2
  74. package/src/ui/manifest/mount-registry.ts +20 -20
  75. package/src/version-manifest.ts +77 -10
package/src/client.ts CHANGED
@@ -1,117 +1,20 @@
1
- import { createORPCClient, onError } from "@orpc/client";
1
+ import { createORPCClient } from "@orpc/client";
2
2
  import { RPCLink } from "@orpc/client/fetch";
3
3
  import type { ContractRouterClient, RouterContract } from "@orpc/contract";
4
4
 
5
- export type RpcConnectionErrorCopy = () => { title: string; description: string };
6
-
7
- export interface RpcClientConfig {
8
- hostUrl: string;
9
- path: `/${string}`;
10
- connectionError?: RpcConnectionErrorCopy;
11
- }
12
-
13
- const RATE_LIMIT_MAX_RETRIES = 3;
14
- const RATE_LIMIT_RETRY_CAP_MS = 2_000;
15
- const browserClients = new Map<string, unknown>();
16
- type FetchInput = Parameters<typeof fetch>[0];
17
-
18
- async function fetchWithRateLimitRetry(
19
- fetchUrl: FetchInput,
20
- options?: RequestInit,
21
- ): Promise<Response> {
22
- let response = await fetch(fetchUrl, options);
23
- for (let attempt = 1; attempt <= RATE_LIMIT_MAX_RETRIES && response.status === 429; attempt++) {
24
- const retryAfterSeconds = Number(response.headers.get("retry-after"));
25
- const delay =
26
- Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
27
- ? Math.min(retryAfterSeconds * 1000, RATE_LIMIT_RETRY_CAP_MS)
28
- : 100 * attempt;
29
- await new Promise((resolve) => setTimeout(resolve, delay));
30
- response = await fetch(fetchUrl, options);
31
- }
32
-
33
- return response;
34
- }
35
-
36
- function mergeHeaders(base: Headers, overrides?: RequestInit["headers"]): Headers {
37
- const merged = new Headers(base);
38
- new Headers(overrides).forEach((value, key) => {
39
- merged.set(key, value);
40
- });
41
- return merged;
42
- }
43
-
44
- function createRpcLink(config: RpcClientConfig, headers?: Headers) {
45
- return new RPCLink({
46
- origin: config.hostUrl,
47
- url: config.path,
48
- interceptors: [
49
- onError((error: unknown) => {
50
- const browserWindow = (globalThis as { window?: unknown }).window;
51
- if (typeof browserWindow === "undefined") {
52
- return;
53
- }
54
-
55
- if (error && typeof error === "object" && "message" in error) {
56
- const message = String(error.message).toLowerCase();
57
- if (
58
- message.includes("fetch") ||
59
- message.includes("network") ||
60
- message.includes("failed to fetch")
61
- ) {
62
- void import("sonner")
63
- .then(({ toast }) => {
64
- const message = config.connectionError?.() ?? {
65
- title: "Unable to connect to API",
66
- description: "The API is currently unavailable. Please try again later.",
67
- };
68
- toast.error(message.title, {
69
- id: "api-connection-error",
70
- description: message.description,
71
- });
72
- })
73
- .catch(() => {});
74
- }
75
- }
76
- }),
77
- ],
78
- fetch(fetchUrl: FetchInput, options?: RequestInit) {
79
- return fetchWithRateLimitRetry(fetchUrl, {
80
- ...options,
81
- credentials: "include",
82
- headers: headers ? mergeHeaders(headers, options?.headers) : options?.headers,
83
- });
84
- },
85
- });
86
- }
87
-
88
- function buildRpcClient<T extends RouterContract>(
89
- config: RpcClientConfig,
90
- headers?: Headers,
91
- ): ContractRouterClient<T> {
92
- if (!config.hostUrl) {
93
- throw new Error("Missing runtime host URL");
94
- }
95
-
96
- return createORPCClient(createRpcLink(config, headers)) as ContractRouterClient<T>;
97
- }
98
-
5
+ /**
6
+ * Bare typed oRPC client over the RPC protocol — a thin convenience over
7
+ * `createORPCClient(new RPCLink({ origin, url }))`.
8
+ *
9
+ * Promise-native, so async/await callers call it directly and Effect callers
10
+ * lift calls with `Effect.tryPromise` (owning timeout/retry/cache in their
11
+ * service layer). Transport customization (headers, timeouts, retries, test
12
+ * doubles) is RPCLink's native surface — construct the link yourself and pass
13
+ * it to `createORPCClient` instead of growing config here.
14
+ */
99
15
  export function createRpcClient<T extends RouterContract = RouterContract>(
100
- config: RpcClientConfig,
101
- headers?: Headers,
16
+ hostUrl: string,
17
+ path: `/${string}` = "/api/rpc",
102
18
  ): ContractRouterClient<T> {
103
- const browserWindow = (globalThis as { window?: unknown }).window;
104
- if (typeof browserWindow !== "undefined" && !headers) {
105
- const key = `${config.hostUrl}${config.path}`;
106
- const memoized = browserClients.get(key);
107
- if (memoized) {
108
- return memoized as ContractRouterClient<T>;
109
- }
110
-
111
- const client = buildRpcClient<T>(config, headers);
112
- browserClients.set(key, client);
113
- return client;
114
- }
115
-
116
- return buildRpcClient<T>(config, headers);
19
+ return createORPCClient(new RPCLink({ origin: hostUrl, url: path })) as ContractRouterClient<T>;
117
20
  }
package/src/errors.ts CHANGED
@@ -68,6 +68,14 @@ export const NOT_FOUND = {
68
68
  }),
69
69
  } as const;
70
70
 
71
+ export const CONFLICT = {
72
+ status: 409,
73
+ data: z.object({
74
+ resource: z.string().optional(),
75
+ detail: z.string().optional(),
76
+ }),
77
+ } as const;
78
+
71
79
  export const FORBIDDEN = {
72
80
  status: 403,
73
81
  data: z.object({
@@ -105,6 +113,7 @@ export const PluginErrors = {
105
113
  SERVICE_UNAVAILABLE,
106
114
  BAD_REQUEST,
107
115
  NOT_FOUND,
116
+ CONFLICT,
108
117
  FORBIDDEN,
109
118
  TIMEOUT,
110
119
  CONNECTION_ERROR,
@@ -123,6 +132,7 @@ export const PLUGIN_ERROR_STATUS_MAP: Record<string, number> = {
123
132
  ...COMMON_ERROR_STATUS_MAP,
124
133
  TIMEOUT: 504,
125
134
  CONNECTION_ERROR: 502,
135
+ CONFLICT: 409,
126
136
  } as const;
127
137
 
128
138
  export type { PluginLoadFailureInfo, PluginWarningInfo } from "./runtime/errors";
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export { getNormalizedRemoteName } from "./runtime/services/normalize";
12
12
  export type {
13
13
  AnyPlugin,
14
14
  EveryPlugin,
15
+ InferRegistryFromEntries,
15
16
  InitializedPlugin,
16
17
  LoadedPlugin,
17
18
  ModuleClientEntry,
@@ -27,6 +28,7 @@ export type {
27
28
  PluginInstance,
28
29
  PluginMetadata,
29
30
  PluginRegistry,
31
+ PluginRegistryEntry,
30
32
  PluginRouterType,
31
33
  PluginRuntimeConfig,
32
34
  PluginSecrets,
@@ -1,9 +1,9 @@
1
1
  import type { AnyRoute } from "@tanstack/react-router";
2
- import { createRootRoute, createRoute, Outlet, redirect } from "@tanstack/react-router";
2
+ import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router";
3
3
  import { CORE_UI_PLUGIN_KEY } from "./contract";
4
4
  import { digestOf } from "./descriptor";
5
5
  import type { PluginManifest, RouteRecord } from "./manifest-schema";
6
- import { MOUNT_REGISTRY, type MountDef, type MountId, resolveMountSegment } from "./mount-registry";
6
+ import { MOUNT_REGISTRY, type MountId, resolveMountSegment } from "./mount-registry";
7
7
  import { compareNavItems, type NavDeclaration, type NavItem, type NavManifest } from "./nav";
8
8
  import { ROUTE_OPTION_KEYS, type RouteConfigModule, type RouteOptionsBundle } from "./route-config";
9
9
 
@@ -11,9 +11,10 @@ import { ROUTE_OPTION_KEYS, type RouteConfigModule, type RouteOptionsBundle } fr
11
11
  * Host-side route-graph construction (ADR 0008 §1): manifests + route
12
12
  * configs in → host-built tree out. The host constructs ITS OWN Route
13
13
  * objects from validated manifest data via public `createRoute` APIs; no
14
- * foreign route object is ever mutated. Gates are attached HOST-side from
15
- * the mount registry — plugins cannot ship a route that escapes its
16
- * mount's gate because plugins never construct route objects.
14
+ * foreign route object is ever mutated. Mount guards are ordinary
15
+ * beforeLoads attached HOST-side from ConstructInput.guards — plugins
16
+ * cannot ship a route that escapes its mount's guard because plugins never
17
+ * construct route objects.
17
18
  *
18
19
  * Mounts are the ONLY cross-source parenting points: a root-level
19
20
  * pathless layout file (`_public.tsx`) declares the mount for the whole
@@ -34,57 +35,6 @@ function toAnyRoute(route: unknown): AnyRoute {
34
35
  return route as AnyRoute;
35
36
  }
36
37
 
37
- export interface GateUser {
38
- id: string;
39
- name?: string;
40
- /** Better Auth user role — "admin" gates the admin mount. */
41
- role?: string;
42
- }
43
-
44
- export interface HostContext {
45
- user?: GateUser;
46
- session?: { user?: GateUser };
47
- }
48
-
49
- type GateFn = (args: {
50
- context: HostContext;
51
- location: { pathname: string; searchStr: string };
52
- }) => void;
53
-
54
- /**
55
- * Gate redirects are absolute: gates attach to root-level mount routes, and
56
- * relatives would silently shift if a mount ever moved deeper in the tree.
57
- * The login surface is plugin-owned, never core-known.
58
- */
59
- const LOGIN_TO: string = "/login";
60
- const HOME_TO: string = "/";
61
-
62
- /**
63
- * Gate implementations by mount gate kind. Only `session` and `admin` are
64
- * implemented (ADR 0008 §3); unimplemented mounts are rejected during mount
65
- * declaration before any gate lookup, and gates are optional by type — a
66
- * mount without an entry (or `none`) renders ungated.
67
- */
68
- const GATES: Partial<Record<MountDef["gate"], GateFn>> = {
69
- session: ({ context, location }) => {
70
- const user = userOf(context);
71
- if (!user) {
72
- throw redirect({
73
- to: LOGIN_TO,
74
- search: { redirect: location.pathname + location.searchStr } as never,
75
- });
76
- }
77
- },
78
- admin: ({ context }) => {
79
- const user = userOf(context);
80
- if (!user) throw redirect({ to: LOGIN_TO });
81
- if (user.role !== "admin") throw redirect({ to: HOME_TO });
82
- },
83
- };
84
-
85
- const userOf = (context: HostContext | undefined): GateUser | undefined =>
86
- context?.user ?? context?.session?.user;
87
-
88
38
  export interface ConstructPluginRef {
89
39
  /** composition identity — plugin key; participates in the digest */
90
40
  key: string;
@@ -104,6 +54,8 @@ export interface ConstructInput {
104
54
  resolve: (ref: ConstructPluginRef) => Promise<ResolvedPlugin>;
105
55
  /** core ui `__root` options — component/error/notFound + head baseline */
106
56
  rootOptions?: RouteOptionsBundle;
57
+ /** Guard per mount id — beforeLoads supplied by the composing framework. */
58
+ guards?: Partial<Record<MountId, RouteOptionsBundle["beforeLoad"]>>;
107
59
  }
108
60
 
109
61
  export interface ConstructedTree {
@@ -139,17 +91,17 @@ const navOf = (options: RouteOptionsBundle): NavDeclaration | undefined => {
139
91
  };
140
92
 
141
93
  /** Layout routes (mount or pathless) pass their authored options through,
142
- * like every other route kind. On mount routes the registry gate composes
94
+ * like every other route kind. On mount routes the mount's guard composes
143
95
  * BEFORE the declaration's own beforeLoad so a source can never escape its
144
- * mount's gate (ADR 0008 §3), while the declaration's return value still
96
+ * mount's guard (ADR 0008 §3), while the declaration's return value still
145
97
  * flows to children as route context. */
146
98
  function layoutBeforeLoad(
147
- gate: GateFn | undefined,
99
+ guard: RouteOptionsBundle["beforeLoad"],
148
100
  declared: RouteOptionsBundle["beforeLoad"],
149
101
  ): RouteOptionsBundle["beforeLoad"] {
150
- if (!gate) return declared;
151
- return (args) => {
152
- gate(args);
102
+ if (!guard) return declared;
103
+ return async (args) => {
104
+ await guard(args);
153
105
  return declared?.(args);
154
106
  };
155
107
  }
@@ -270,13 +222,16 @@ export async function constructTree(input: ConstructInput): Promise<ConstructedT
270
222
  const declaration = declarations.get(mountId);
271
223
  if (!declaration) continue;
272
224
  const def = MOUNT_REGISTRY[mountId]!;
273
- const gate = GATES[def.gate];
225
+ const guard = input.guards?.[mountId];
226
+ if (def.guarded && !guard) {
227
+ throw new Error(`mount "${mountId}" requires a guard — add one to ConstructInput.guards`);
228
+ }
274
229
  const options = declaration.options;
275
230
  const route = createRoute({
276
231
  id: `__mount_${mountId}`,
277
232
  getParentRoute: () => rootRoute,
278
- ...(gate || options.beforeLoad
279
- ? { beforeLoad: layoutBeforeLoad(gate, options.beforeLoad) }
233
+ ...(guard || options.beforeLoad
234
+ ? { beforeLoad: layoutBeforeLoad(guard, options.beforeLoad) }
280
235
  : {}),
281
236
  ...authoredOptions(options),
282
237
  component: options.component ?? Outlet,
@@ -10,8 +10,6 @@ export {
10
10
  type ConstructInput,
11
11
  type ConstructPluginRef,
12
12
  constructTree,
13
- type GateUser,
14
- type HostContext,
15
13
  type ResolvedPlugin,
16
14
  } from "./construct";
17
15
  export * from "./contract";
@@ -1,40 +1,40 @@
1
1
  /**
2
- * Mount registry v2 — gates only, shape-free (ADR 0008 §3).
2
+ * Mount registry v2 — shape-free (ADR 0008 §3).
3
3
  *
4
- * A mount is a gate + a host-owned pathless layout position. It carries no
5
- * product semantics (no "dashboard", no shell, no chrome) — plugins bring
6
- * their own layouts and route freely inside their subtree. A mount is
7
- * declared by a ROOT-level pathless layout file whose `_`-prefixed segment
8
- * names the mount (`_public.tsx` → `public`); the generator derives it and
9
- * validates it against this registry; gates are attached HOST-side at
10
- * construction, so a plugin cannot ship a route that escapes its mount's
11
- * gate (plugins never construct route objects).
4
+ * A mount is a host-owned pathless layout position. It carries no product
5
+ * semantics (no "dashboard", no shell, no chrome) — plugins bring their own
6
+ * layouts and route freely inside their subtree. A mount is declared by a
7
+ * ROOT-level pathless layout file whose `_`-prefixed segment names the mount
8
+ * (`_public.tsx` → `public`); the generator derives it and validates it
9
+ * against this registry; guards are ordinary beforeLoads attached HOST-side
10
+ * at construction from ConstructInput.guards, so a plugin cannot ship a
11
+ * route that escapes its mount's guard (plugins never construct route
12
+ * objects).
12
13
  *
13
- * `org`/`team` are declared vocabulary (parameterized membership gates) but
14
+ * `org`/`team` are declared vocabulary (parameterized membership) but
14
15
  * unimplemented — construction rejects them until they land.
15
16
  */
16
17
 
17
18
  export interface MountDef {
18
- label: string;
19
- gate: "none" | "session" | "admin" | "organization" | "team";
20
- parameterized?: boolean;
19
+ /** The composing framework supplies this mount's guard via ConstructInput.guards. */
20
+ guarded: boolean;
21
21
  implemented: boolean;
22
22
  }
23
23
 
24
24
  export const MOUNT_REGISTRY = {
25
- public: { label: "public", gate: "none", implemented: true },
26
- authenticated: { label: "authenticated", gate: "session", implemented: true },
27
- admin: { label: "admin", gate: "admin", implemented: true },
28
- org: { label: "org", gate: "organization", parameterized: true, implemented: false },
29
- team: { label: "team", gate: "team", parameterized: true, implemented: false },
30
- } satisfies Record<string, MountDef>;
25
+ public: { guarded: false, implemented: true },
26
+ authenticated: { guarded: true, implemented: true },
27
+ admin: { guarded: true, implemented: true },
28
+ org: { guarded: true, implemented: false },
29
+ team: { guarded: true, implemented: false },
30
+ } as const satisfies Record<string, MountDef>;
31
31
 
32
32
  export type MountId = Extract<keyof typeof MOUNT_REGISTRY, string>;
33
33
 
34
34
  export const MOUNTS = Object.keys(MOUNT_REGISTRY) as MountId[];
35
35
 
36
36
  /** Bump when the registry shape changes — invalidates all compose digests. */
37
- export const MOUNT_REGISTRY_VERSION = 5;
37
+ export const MOUNT_REGISTRY_VERSION = 6;
38
38
 
39
39
  /** Migration aliases: legacy `_`-segment names → canonical mounts. */
40
40
  export const MOUNT_ALIASES: Record<string, MountId> = {
@@ -12,10 +12,38 @@ import { z } from "zod";
12
12
 
13
13
  const SRI = z.string().regex(/^sha384-[A-Za-z0-9+/=]+$/);
14
14
 
15
+ /** Slot-relative directory holding one deploy's immutable file copies (#826). */
16
+ export const VERSION_DIR_PATTERN = /^versions\/[0-9a-f]{16}$/;
17
+
18
+ /** The attribution block stored in the manifest's `signature` field. The
19
+ * verifying key rides along so a verifier can distinguish "wrong signature"
20
+ * from "signed by a key the config never registered" — both are
21
+ * `SignatureInvalid`, but the reasons name different compromises. */
22
+ export const ManifestSignatureSchema = z.object({
23
+ algorithm: z.literal("ed25519"),
24
+ /** NEAR-format verifying key (`ed25519:<base58>`) — must equal the
25
+ * config's `publish.verification.publicKey`. */
26
+ publicKey: z.string().min(1),
27
+ /** base64 ed25519 signature over the canonical payload bytes. */
28
+ value: z.string().min(1),
29
+ });
30
+ export type ManifestSignature = z.infer<typeof ManifestSignatureSchema>;
31
+
15
32
  export const WorkspaceVersionManifestSchema = z.object({
16
33
  version: z.string().regex(/^[0-9a-f]{16}$/),
17
34
  builtAt: z.string().optional(),
18
35
  gitSha: z.string().optional(),
36
+ /**
37
+ * Slot-relative directory (`versions/<dirId>`) holding this deploy's
38
+ * byte-immutable copies of every dist file (#826). When present, all
39
+ * artifact coordinates below resolve against `<slotBase>/<dir>/` — so a
40
+ * pinned child's entry fetches its expose chunk and chunks from the same
41
+ * frozen directory instead of the mutable slot root. Absent on legacy
42
+ * manifests: coordinates resolve against the slot root exactly as before.
43
+ * Derived from the `files` map (see `versionDirPath`), so it participates
44
+ * in the version hash without circularity.
45
+ */
46
+ dir: z.string().regex(VERSION_DIR_PATTERN).optional(),
19
47
  entry: z.string().min(1),
20
48
  entryIntegrity: SRI,
21
49
  ssr: z
@@ -55,9 +83,47 @@ export const WorkspaceVersionManifestSchema = z.object({
55
83
  integrity: SRI,
56
84
  })
57
85
  .optional(),
86
+ /**
87
+ * Attribution (ADR 0046 ticket 05): the deploy train's ed25519 signature
88
+ * over `signaturePayloadOf(this document)` — SRI attests WHAT the bytes
89
+ * are, the signature attests WHO published them, with the verifying key
90
+ * anchored in the on-chain config (`publish.verification`). Absent on
91
+ * rollouts with attribution off; readers that predate the field ignore it
92
+ * (zod strips unknown keys). Excluded from the version-id derivation —
93
+ * like `builtAt`, it is not a coordinate of the build.
94
+ */
95
+ signature: ManifestSignatureSchema.optional(),
58
96
  });
59
97
  export type WorkspaceVersionManifest = z.infer<typeof WorkspaceVersionManifestSchema>;
60
98
 
99
+ /** Canonical key-sorted JSON serialization — the same form the version-id
100
+ * derivation hashes (undefined-valued entries dropped). Exported so the
101
+ * signature payload and the version id can never disagree about what
102
+ * "canonical" means. */
103
+ export function stableStringify(value: unknown): string {
104
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
105
+ if (value && typeof value === "object") {
106
+ return `{${Object.entries(value)
107
+ .filter(([, v]) => v !== undefined)
108
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`)
109
+ .sort()
110
+ .join(",")}}`;
111
+ }
112
+ return JSON.stringify(value) ?? "null";
113
+ }
114
+
115
+ /**
116
+ * The bytes a manifest signature attests: the document minus its reserved
117
+ * `signature` field, canonically serialized (key-sorted — the version-id
118
+ * rule). The signer signs this before the field exists; the verifier
119
+ * re-derives it from the parsed document, so the stored document's
120
+ * whitespace never matters.
121
+ */
122
+ export function signaturePayloadOf(manifest: WorkspaceVersionManifest): string {
123
+ const { signature: _signature, ...payload } = manifest;
124
+ return stableStringify(payload);
125
+ }
126
+
61
127
  /** A published-artifact coordinate: object path within the bundle namespace + its served SRI. */
62
128
  export type SourceRef = NonNullable<WorkspaceVersionManifest["source"]>;
63
129
 
@@ -67,6 +133,7 @@ export interface VersionManifestInput {
67
133
  * staged copy and the CDN's immutability for republished versions. */
68
134
  builtAt?: string;
69
135
  gitSha?: string;
136
+ dir?: string;
70
137
  entry: string;
71
138
  entryIntegrity: string;
72
139
  ssr?: { entry: string; integrity: string };
@@ -93,14 +160,14 @@ export function composeVersionManifest(input: VersionManifestInput): WorkspaceVe
93
160
  return WorkspaceVersionManifestSchema.parse({ ...input, version });
94
161
  }
95
162
 
96
- function stableStringify(value: unknown): string {
97
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
98
- if (value && typeof value === "object") {
99
- return `{${Object.entries(value)
100
- .filter(([, v]) => v !== undefined)
101
- .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`)
102
- .sort()
103
- .join(",")}}`;
104
- }
105
- return JSON.stringify(value) ?? "null";
163
+ /**
164
+ * The content-addressed directory name for one dist's file set (#826): a
165
+ * 16-hex hash over the canonical files map. Identical bytes → identical
166
+ * directory → re-deploying unchanged content is a server-side no-op, and a
167
+ * published pin's directory can never be rewritten by a later deploy
168
+ * (the upload service refuses conflicting writes under `versions/`).
169
+ */
170
+ export function versionDirPath(files: Record<string, string>): string {
171
+ const id = createHash("sha256").update(stableStringify(files)).digest("hex").slice(0, 16);
172
+ return `versions/${id}`;
106
173
  }