@arcote.tech/arc-cli 0.7.16 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.7.16",
3
+ "version": "0.7.17",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,13 +12,13 @@
12
12
  "build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform --external '@opentelemetry/*' && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.7.16",
16
- "@arcote.tech/arc-ds": "^0.7.16",
17
- "@arcote.tech/arc-react": "^0.7.16",
18
- "@arcote.tech/arc-host": "^0.7.16",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.16",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.7.16",
21
- "@arcote.tech/arc-otel": "^0.7.16",
15
+ "@arcote.tech/arc": "^0.7.17",
16
+ "@arcote.tech/arc-ds": "^0.7.17",
17
+ "@arcote.tech/arc-react": "^0.7.17",
18
+ "@arcote.tech/arc-host": "^0.7.17",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.17",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.7.17",
21
+ "@arcote.tech/arc-otel": "^0.7.17",
22
22
  "@opentelemetry/api": "^1.9.0",
23
23
  "@opentelemetry/api-logs": "^0.57.0",
24
24
  "@opentelemetry/core": "^1.30.0",
@@ -31,7 +31,7 @@
31
31
  "@opentelemetry/sdk-trace-base": "^1.30.0",
32
32
  "@opentelemetry/sdk-trace-node": "^1.30.0",
33
33
  "@opentelemetry/semantic-conventions": "^1.27.0",
34
- "@arcote.tech/platform": "^0.7.16",
34
+ "@arcote.tech/platform": "^0.7.17",
35
35
  "@clack/prompts": "^0.9.0",
36
36
  "commander": "^11.1.0",
37
37
  "chokidar": "^3.5.3",
@@ -1,4 +1,5 @@
1
- import { basename } from "path";
1
+ import { readFileSync, readdirSync, statSync } from "fs";
2
+ import { basename, join } from "path";
2
3
  import type { SerializedAccessMap } from "./access-extractor";
3
4
  import type { WorkspacePackage } from "./module-builder";
4
5
 
@@ -79,6 +80,78 @@ export function planChunks(
79
80
  return { assignments, groups, chunks };
80
81
  }
81
82
 
83
+ // ---------------------------------------------------------------------------
84
+ // Single-module-per-package invariant
85
+ //
86
+ // Chunk grouping is per workspace PACKAGE (planChunks reads the access of the
87
+ // one module derived from the package name and assigns the whole package to
88
+ // that token's chunk). A second `module()` in the same package is invisible to
89
+ // the planner — it silently rides the first module's chunk and token gating.
90
+ // That's how a public/userToken onboarding page can end up trapped in a
91
+ // workspaceToken chunk. Fail the build loudly instead.
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const MODULE_CALL = /\bmodule\(\s*["'`]([a-zA-Z0-9_-]+)["'`]/g;
95
+
96
+ function collectModuleNames(dir: string, acc: Set<string>): void {
97
+ let entries: string[];
98
+ try {
99
+ entries = readdirSync(dir);
100
+ } catch {
101
+ return;
102
+ }
103
+ for (const e of entries) {
104
+ if (e === "node_modules" || e === "dist") continue;
105
+ const full = join(dir, e);
106
+ let isDir = false;
107
+ try {
108
+ isDir = statSync(full).isDirectory();
109
+ } catch {
110
+ continue;
111
+ }
112
+ if (isDir) {
113
+ collectModuleNames(full, acc);
114
+ } else if (e.endsWith(".ts") || e.endsWith(".tsx")) {
115
+ const src = readFileSync(full, "utf-8");
116
+ MODULE_CALL.lastIndex = 0;
117
+ let m: RegExpExecArray | null;
118
+ while ((m = MODULE_CALL.exec(src))) acc.add(m[1]);
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Assert every workspace package declares at most one `module()`. Throws a
125
+ * build error listing offenders, with the fix (split each extra module into
126
+ * its own package). Call before chunk planning.
127
+ */
128
+ export function assertOneModulePerPackage(
129
+ packages: readonly WorkspacePackage[],
130
+ ): void {
131
+ const offenders: { pkg: string; modules: string[] }[] = [];
132
+ for (const pkg of packages) {
133
+ const names = new Set<string>();
134
+ collectModuleNames(join(pkg.path, "src"), names);
135
+ if (names.size > 1) {
136
+ offenders.push({ pkg: pkg.name, modules: [...names].sort() });
137
+ }
138
+ }
139
+ if (offenders.length === 0) return;
140
+
141
+ const detail = offenders
142
+ .map((o) => ` • ${o.pkg} declares ${o.modules.length}: ${o.modules.join(", ")}`)
143
+ .join("\n");
144
+ throw new Error(
145
+ `Arc build: a workspace package must declare at most ONE module().\n` +
146
+ `Chunk grouping is per-package — a second module() silently inherits the ` +
147
+ `first module's token protection and chunk, so e.g. a public onboarding ` +
148
+ `page sharing a package with a workspaceToken-gated module becomes ` +
149
+ `unreachable for users without that token.\n${detail}\n` +
150
+ `Fix: move each extra module() into its own workspace package ` +
151
+ `(declare its own protectedBy there).`,
152
+ );
153
+ }
154
+
82
155
  // ---------------------------------------------------------------------------
83
156
  // Helpers
84
157
  // ---------------------------------------------------------------------------
@@ -95,13 +168,24 @@ function resolveChunk(
95
168
  if (!access || access.rules.length === 0) return PUBLIC_CHUNK;
96
169
 
97
170
  const tokenNames = new Set(access.rules.map((r) => r.token.name));
98
- if (tokenNames.size === 1) {
99
- return [...tokenNames][0];
171
+ if (tokenNames.size > 1) {
172
+ const list = [...tokenNames].sort().join(", ");
173
+ throw new Error(
174
+ `Module "${moduleName}" has access rules for multiple tokens [${list}]. ` +
175
+ `Multi-token modules are not supported — split the module or unify on a single token type.`,
176
+ );
100
177
  }
101
178
 
102
- const list = [...tokenNames].sort().join(", ");
103
- throw new Error(
104
- `Module "${moduleName}" has access rules for multiple tokens [${list}]. ` +
105
- `Multi-token modules are not supported split the module or unify on a single token type.`,
106
- );
179
+ const tokenName = [...tokenNames][0];
180
+ const hasCheck = access.rules.some((r) => r.hasCheck);
181
+
182
+ // A module with a check (e.g. `protectedBy(userToken, t => t.role==='admin')`)
183
+ // has NARROWER access than the bare token. The server grants a chunk
184
+ // all-or-nothing: it's served only if EVERY module in it passes its check.
185
+ // So a checked module sharing a chunk with token-only modules would block
186
+ // them for users who hold the token but fail the check (an admin gate
187
+ // trapping an onboarding page). Give each checked module its own chunk;
188
+ // token-only modules group together under the token name. Chunk name must
189
+ // be filesystem/URL-safe (BROWSER_FILE_RE) — `__` separator, not `:`.
190
+ return hasCheck ? `${tokenName}__${moduleName}` : tokenName;
107
191
  }
@@ -266,9 +266,11 @@ function parseArcTokensHeader(header: string | null): any[] {
266
266
 
267
267
  /**
268
268
  * Filter manifest groups to those the caller's tokens grant access to. A
269
- * group is unlocked when (a) the caller carries a token whose `tokenType`
270
- * matches the group name, and (b) every per-module rule declared via
271
- * `protectedBy(token, check)` passes for at least one of those tokens.
269
+ * group is unlocked when EVERY module in it is granted: the caller holds a
270
+ * token whose name matches one of the module's `protectedBy(token, check)`
271
+ * rules, and the rule's check (if any) passes. Group names are not 1:1 with
272
+ * token names — a checked module gets its own chunk (`<token>__<module>`),
273
+ * so grants are resolved per-module via the access map, not by group name.
272
274
  *
273
275
  * Returns a manifest with `groups` filtered + each remaining entry's `url`
274
276
  * filled with a signed URL (HMAC, 1h TTL). `initial` + `sharedChunks` ride
@@ -279,26 +281,27 @@ async function filterManifestForTokens(
279
281
  moduleAccessMap: Map<string, ModuleAccess>,
280
282
  tokenPayloads: any[],
281
283
  ): Promise<BuildManifest> {
282
- const allowedGroups = new Set<string>();
284
+ // Chunk group names are no longer 1:1 with token names (a checked module
285
+ // gets its own chunk `<token>__<module>`), so grant by the per-module
286
+ // access rules, not by the group name. Look tokens up by their name.
287
+ const tokensByName = new Map<string, any>();
283
288
  for (const t of tokenPayloads) {
284
- if (t?.tokenType) allowedGroups.add(t.tokenType);
289
+ if (t?.tokenType) tokensByName.set(t.tokenType, t);
285
290
  }
286
291
 
287
292
  const filteredGroups: Record<string, BuildManifestGroup> = {};
288
293
 
289
294
  for (const [name, group] of Object.entries(manifest.groups)) {
290
- if (!allowedGroups.has(name)) continue;
291
-
292
- // Every module in the group must pass its per-rule check (if any).
293
- // If all rules pass (or none declared), unlock the group bundle.
295
+ // Every module in the group must be granted by a held token (and pass
296
+ // its check, if any). Chunks are homogeneous post-split — a checked
297
+ // module is alone in its chunk, so its check can't block other modules.
294
298
  let allGranted = true;
295
299
  for (const moduleName of group.modules) {
296
300
  const access = moduleAccessMap.get(moduleName);
297
301
  if (!access || access.rules.length === 0) continue;
298
302
  let granted = false;
299
303
  for (const rule of access.rules) {
300
- if (rule.token.name !== name) continue;
301
- const matching = tokenPayloads.find((t) => t.tokenType === name);
304
+ const matching = tokensByName.get(rule.token.name);
302
305
  if (!matching) continue;
303
306
  granted = rule.check ? await rule.check(matching) : true;
304
307
  if (granted) break;
@@ -21,7 +21,7 @@ import {
21
21
  type BuildCache,
22
22
  } from "../builder/build-cache";
23
23
  import { extractAccessMap } from "../builder/access-extractor";
24
- import { planChunks } from "../builder/chunk-planner";
24
+ import { assertOneModulePerPackage, planChunks } from "../builder/chunk-planner";
25
25
  import { collectFrameworkDeps } from "../builder/dependency-collector";
26
26
  import {
27
27
  mtimeOf,
@@ -157,6 +157,10 @@ export async function buildAll(
157
157
 
158
158
  log(`Building (concurrency parallel${noCache ? ", no-cache" : ""})...`);
159
159
 
160
+ // Phase 0 — invariant check before any work: one module() per package,
161
+ // else chunk grouping (per-package) silently mis-assigns the extra module.
162
+ assertOneModulePerPackage(ws.packages);
163
+
160
164
  // Phase 1 — context packages must finish FIRST. The access-extractor
161
165
  // subprocess imports workspace packages by name, which resolve through
162
166
  // node_modules to packages' `main` field (typically `dist/server/main/`).