@arcote.tech/arc-cli 0.4.6 → 0.4.8

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.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -97,8 +97,13 @@ export interface WorkspacePackage {
97
97
  packageJson: Record<string, any>;
98
98
  }
99
99
 
100
+ export interface ModuleEntry {
101
+ file: string;
102
+ name: string;
103
+ }
104
+
100
105
  export interface BuildManifest {
101
- modules: string[];
106
+ modules: ModuleEntry[];
102
107
  buildTime: string;
103
108
  }
104
109
 
@@ -203,9 +208,13 @@ export async function buildPackages(
203
208
  mkdirSync(tmpDir, { recursive: true });
204
209
 
205
210
  const entrypoints: string[] = [];
211
+ const fileToName = new Map<string, string>(); // safeName → module name
206
212
 
207
213
  for (const pkg of packages) {
208
214
  const safeName = pkg.path.split("/").pop()!;
215
+ // Module name: strip scope (e.g. @ndt/admin → admin)
216
+ const moduleName = pkg.name.includes("/") ? pkg.name.split("/").pop()! : pkg.name;
217
+ fileToName.set(safeName, moduleName);
209
218
 
210
219
  // All packages get a simple re-export wrapper.
211
220
  // Context packages that use module().build() self-register via side effects.
@@ -262,12 +271,16 @@ export async function buildPackages(
262
271
  rmSync(tmpDir, { recursive: true, force: true });
263
272
 
264
273
  // Build manifest
265
- const moduleFiles = result.outputs
274
+ const moduleEntries: ModuleEntry[] = result.outputs
266
275
  .filter((o) => o.kind === "entry-point")
267
- .map((o) => o.path.split("/").pop()!);
276
+ .map((o) => {
277
+ const file = o.path.split("/").pop()!;
278
+ const safeName = file.replace(/\.js$/, "");
279
+ return { file, name: fileToName.get(safeName) ?? safeName };
280
+ });
268
281
 
269
282
  const manifest: BuildManifest = {
270
- modules: moduleFiles,
283
+ modules: moduleEntries,
271
284
  buildTime: new Date().toISOString(),
272
285
  };
273
286
 
@@ -6,6 +6,7 @@ import {
6
6
  buildAll,
7
7
  buildPackages,
8
8
  buildStyles,
9
+ collectArcPeerDeps,
9
10
  loadServerContext,
10
11
  log,
11
12
  ok,
@@ -21,7 +22,7 @@ export async function platformDev(): Promise<void> {
21
22
 
22
23
  // Load server context
23
24
  log("Loading server context...");
24
- const context = await loadServerContext(ws.packages);
25
+ const { context, moduleAccess } = await loadServerContext(ws.packages);
25
26
  if (context) {
26
27
  ok("Context loaded");
27
28
  } else {
@@ -29,13 +30,16 @@ export async function platformDev(): Promise<void> {
29
30
  }
30
31
 
31
32
  // Start server (dev mode = SSE reload + no-cache)
33
+ const arcEntries = collectArcPeerDeps(ws.packages);
32
34
  const platform = await startPlatformServer({
33
35
  ws,
34
36
  port,
35
37
  manifest,
36
38
  context,
39
+ moduleAccess,
37
40
  dbPath: join(ws.rootDir, ".arc", "data", "dev.db"),
38
41
  devMode: true,
42
+ arcEntries,
39
43
  });
40
44
 
41
45
  ok(`Server on http://localhost:${port}`);
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { startPlatformServer } from "../platform/server";
4
4
  import {
5
+ collectArcPeerDeps,
5
6
  err,
6
7
  loadServerContext,
7
8
  log,
@@ -26,7 +27,7 @@ export async function platformStart(): Promise<void> {
26
27
 
27
28
  // Load server context
28
29
  log("Loading server context...");
29
- const context = await loadServerContext(ws.packages);
30
+ const { context, moduleAccess } = await loadServerContext(ws.packages);
30
31
  if (context) {
31
32
  ok("Context loaded");
32
33
  } else {
@@ -34,13 +35,16 @@ export async function platformStart(): Promise<void> {
34
35
  }
35
36
 
36
37
  // Start server (production mode = no SSE reload, aggressive caching)
38
+ const arcEntries = collectArcPeerDeps(ws.packages);
37
39
  const platform = await startPlatformServer({
38
40
  ws,
39
41
  port,
40
42
  manifest,
41
43
  context,
44
+ moduleAccess,
42
45
  dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
43
46
  devMode: false,
47
+ arcEntries,
44
48
  });
45
49
 
46
50
  ok(`Server on http://localhost:${port}`);
@@ -18,6 +18,7 @@ export interface CatalogEntry {
18
18
  locations: string[];
19
19
  hash: string;
20
20
  obsolete: boolean;
21
+ manual?: boolean;
21
22
  }
22
23
 
23
24
  /** Short hash from msgid for change tracking */
@@ -37,6 +38,7 @@ export function parsePo(content: string): CatalogEntry[] {
37
38
  let msgid = "";
38
39
  let msgstr = "";
39
40
  let obsolete = false;
41
+ let inManual = false;
40
42
 
41
43
  const flush = () => {
42
44
  if (msgid) {
@@ -46,6 +48,7 @@ export function parsePo(content: string): CatalogEntry[] {
46
48
  locations,
47
49
  hash: hash || hashMsgid(msgid),
48
50
  obsolete,
51
+ ...(inManual ? { manual: true } : {}),
49
52
  });
50
53
  }
51
54
  locations = [];
@@ -58,6 +61,17 @@ export function parsePo(content: string): CatalogEntry[] {
58
61
  for (const line of lines) {
59
62
  const trimmed = line.trim();
60
63
 
64
+ // Manual section markers
65
+ if (trimmed === "# manual") {
66
+ inManual = true;
67
+ continue;
68
+ }
69
+ if (trimmed === "# end manual") {
70
+ if (msgid) flush();
71
+ inManual = false;
72
+ continue;
73
+ }
74
+
61
75
  if (trimmed === "" || trimmed.startsWith("#,")) {
62
76
  // Empty line or flags — boundary between entries
63
77
  if (msgid) flush();
@@ -110,10 +124,23 @@ export function parsePo(content: string): CatalogEntry[] {
110
124
  export function writePo(entries: CatalogEntry[]): string {
111
125
  const lines: string[] = [];
112
126
 
113
- // Active entries first, then obsolete
114
- const active = entries.filter((e) => !e.obsolete);
127
+ const manual = entries.filter((e) => e.manual);
128
+ const active = entries.filter((e) => !e.obsolete && !e.manual);
115
129
  const obsolete = entries.filter((e) => e.obsolete);
116
130
 
131
+ // Manual section
132
+ if (manual.length > 0) {
133
+ lines.push("# manual");
134
+ for (const entry of manual) {
135
+ lines.push(`msgid ${quoteString(entry.msgid)}`);
136
+ lines.push(`msgstr ${quoteString(entry.msgstr)}`);
137
+ lines.push("");
138
+ }
139
+ lines.push("# end manual");
140
+ lines.push("");
141
+ }
142
+
143
+ // Extracted entries
117
144
  for (const entry of active) {
118
145
  for (const loc of entry.locations) {
119
146
  lines.push(`#: ${loc}`);
@@ -124,6 +151,7 @@ export function writePo(entries: CatalogEntry[]): string {
124
151
  lines.push("");
125
152
  }
126
153
 
154
+ // Obsolete entries
127
155
  if (obsolete.length > 0) {
128
156
  lines.push("# Obsolete entries");
129
157
  lines.push("");
@@ -152,12 +180,18 @@ export function mergeCatalog(
152
180
  existingMap.set(entry.msgid, entry);
153
181
  }
154
182
 
183
+ // Preserve manual entries as-is
184
+ const manualEntries = existing.filter((e) => e.manual);
185
+ const manualIds = new Set(manualEntries.map((e) => e.msgid));
186
+
155
187
  const result: CatalogEntry[] = [];
156
188
  const seen = new Set<string>();
157
189
 
158
- // Process all extracted messages
190
+ // Process all extracted messages (skip those in manual section)
159
191
  for (const [msgid, locations] of extracted) {
160
192
  seen.add(msgid);
193
+ if (manualIds.has(msgid)) continue;
194
+
161
195
  const prev = existingMap.get(msgid);
162
196
 
163
197
  result.push({
@@ -171,7 +205,7 @@ export function mergeCatalog(
171
205
 
172
206
  // Mark removed entries as obsolete (keep their translations)
173
207
  for (const entry of existing) {
174
- if (!seen.has(entry.msgid) && !entry.obsolete && entry.msgstr) {
208
+ if (!seen.has(entry.msgid) && !entry.obsolete && !entry.manual && entry.msgstr) {
175
209
  result.push({
176
210
  ...entry,
177
211
  obsolete: true,
@@ -180,7 +214,11 @@ export function mergeCatalog(
180
214
  }
181
215
  }
182
216
 
183
- return result;
217
+ // Sort extracted entries alphabetically for deterministic output
218
+ result.sort((a, b) => a.msgid.localeCompare(b.msgid));
219
+
220
+ // Manual entries first, then sorted extracted, then obsolete
221
+ return [...manualEntries, ...result];
184
222
  }
185
223
 
186
224
  function extractQuoted(s: string): string {
@@ -18,7 +18,12 @@ export function compileCatalog(poPath: string): Record<string, string> {
18
18
  }
19
19
  }
20
20
 
21
- return result;
21
+ // Sort keys for deterministic JSON output
22
+ const sorted: Record<string, string> = {};
23
+ for (const key of Object.keys(result).sort()) {
24
+ sorted[key] = result[key];
25
+ }
26
+ return sorted;
22
27
  }
23
28
 
24
29
  /**
@@ -11,7 +11,8 @@ import {
11
11
  import { existsSync, mkdirSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { readTranslationsConfig } from "../i18n";
14
- import type { BuildManifest, WorkspaceInfo } from "./shared";
14
+ import type { BuildManifest, ModuleEntry, WorkspaceInfo } from "./shared";
15
+ import type { ModuleAccess } from "@arcote.tech/platform";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Types
@@ -23,10 +24,14 @@ export interface PlatformServerOptions {
23
24
  manifest: BuildManifest;
24
25
  /** Server context (from loadServerContext). If null, static-only mode. */
25
26
  context?: any;
27
+ /** Module access rules (from registry after loadServerContext). */
28
+ moduleAccess?: Map<string, ModuleAccess>;
26
29
  /** Path to SQLite database file */
27
30
  dbPath?: string;
28
31
  /** If true, enables SSE reload stream + mutable manifest (dev mode) */
29
32
  devMode?: boolean;
33
+ /** Arc shell entries [shortName, fullPkgName][] for import map. Auto-detected if omitted. */
34
+ arcEntries?: [string, string][];
30
35
  }
31
36
 
32
37
  export interface PlatformServer {
@@ -66,7 +71,17 @@ export async function initContextHandler(
66
71
  // Shell HTML
67
72
  // ---------------------------------------------------------------------------
68
73
 
69
- export function generateShellHtml(appName: string, manifest?: { title: string; favicon?: string }): string {
74
+ export function generateShellHtml(
75
+ appName: string,
76
+ manifest?: { title: string; favicon?: string },
77
+ arcEntries?: [string, string][],
78
+ ): string {
79
+ const arcImports: Record<string, string> = {};
80
+ if (arcEntries) {
81
+ for (const [short, pkg] of arcEntries) {
82
+ arcImports[pkg] = `/shell/${short}.js`;
83
+ }
84
+ }
70
85
  const importMap = {
71
86
  imports: {
72
87
  react: "/shell/react.js",
@@ -74,13 +89,7 @@ export function generateShellHtml(appName: string, manifest?: { title: string; f
74
89
  "react/jsx-dev-runtime": "/shell/jsx-dev-runtime.js",
75
90
  "react-dom": "/shell/react-dom.js",
76
91
  "react-dom/client": "/shell/react-dom-client.js",
77
- "@arcote.tech/arc": "/shell/arc.js",
78
- "@arcote.tech/arc-ds": "/shell/arc-ds.js",
79
- "@arcote.tech/arc-react": "/shell/arc-react.js",
80
- "@arcote.tech/arc-auth": "/shell/arc-auth.js",
81
- "@arcote.tech/arc-utils": "/shell/arc-utils.js",
82
- "@arcote.tech/arc-workspace": "/shell/arc-workspace.js",
83
- "@arcote.tech/platform": "/shell/platform.js",
92
+ ...arcImports,
84
93
  },
85
94
  };
86
95
 
@@ -144,20 +153,140 @@ function serveFile(
144
153
  });
145
154
  }
146
155
 
156
+ // ---------------------------------------------------------------------------
157
+ // Module access — signed URLs
158
+ // ---------------------------------------------------------------------------
159
+
160
+ const MODULE_SIG_SECRET = process.env.ARC_MODULE_SECRET ?? crypto.randomUUID();
161
+ const MODULE_SIG_TTL = 3600; // 1 hour
162
+
163
+ function signModuleUrl(filename: string): string {
164
+ const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
165
+ const hasher = new Bun.CryptoHasher("sha256");
166
+ hasher.update(`${filename}:${exp}:${MODULE_SIG_SECRET}`);
167
+ const sig = hasher.digest("hex").slice(0, 16);
168
+ return `/modules/${filename}?sig=${sig}&exp=${exp}`;
169
+ }
170
+
171
+ function verifyModuleSignature(filename: string, sig: string | null, exp: string | null): boolean {
172
+ if (!sig || !exp) return false;
173
+ if (Number(exp) < Date.now() / 1000) return false;
174
+ const hasher = new Bun.CryptoHasher("sha256");
175
+ hasher.update(`${filename}:${exp}:${MODULE_SIG_SECRET}`);
176
+ return hasher.digest("hex").slice(0, 16) === sig;
177
+ }
178
+
179
+ /** Decode JWT payload without verification (for module manifest filtering).
180
+ * Normalizes tokenName → tokenType to match TokenPayload interface. */
181
+ function decodeTokenPayload(jwt: string): any | null {
182
+ try {
183
+ const parts = jwt.split(".");
184
+ if (parts.length !== 3) return null;
185
+ const payload = JSON.parse(atob(parts[1]));
186
+ return {
187
+ tokenType: payload.tokenName ?? payload.tokenType,
188
+ params: payload.params || {},
189
+ iat: payload.iat,
190
+ exp: payload.exp,
191
+ };
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /** Parse X-Arc-Tokens header: "scope1:jwt1,scope2:jwt2" → decoded payloads array */
198
+ function parseArcTokensHeader(header: string | null): any[] {
199
+ if (!header) return [];
200
+ const payloads: any[] = [];
201
+ for (const entry of header.split(",")) {
202
+ const colonIdx = entry.indexOf(":");
203
+ if (colonIdx < 0) continue;
204
+ const jwt = entry.slice(colonIdx + 1);
205
+ const payload = decodeTokenPayload(jwt);
206
+ if (payload) payloads.push(payload);
207
+ }
208
+ return payloads;
209
+ }
210
+
211
+ async function filterManifestForTokens(
212
+ manifest: BuildManifest,
213
+ moduleAccessMap: Map<string, ModuleAccess>,
214
+ tokenPayloads: any[],
215
+ ): Promise<BuildManifest> {
216
+ const filtered: ModuleEntry[] = [];
217
+
218
+ console.log(`[arc:modules] Filtering ${manifest.modules.length} modules with ${tokenPayloads.length} token(s):`,
219
+ tokenPayloads.map(t => `${t.tokenType}(${JSON.stringify(t.params)})`).join(", ") || "none");
220
+ console.log(`[arc:modules] Protected modules:`, [...moduleAccessMap.keys()].join(", ") || "none");
221
+
222
+ for (const mod of manifest.modules) {
223
+ const access = moduleAccessMap.get(mod.name);
224
+
225
+ if (!access) {
226
+ filtered.push(mod);
227
+ continue;
228
+ }
229
+
230
+ if (tokenPayloads.length === 0) {
231
+ console.log(`[arc:modules] ${mod.name}: SKIP (no tokens)`);
232
+ continue;
233
+ }
234
+
235
+ let granted = false;
236
+ for (const rule of access.rules) {
237
+ const matching = tokenPayloads.find(
238
+ (t) => t.tokenType === rule.token.name,
239
+ );
240
+ if (matching) {
241
+ granted = rule.check ? await rule.check(matching) : true;
242
+ console.log(`[arc:modules] ${mod.name}: rule ${rule.token.name} matched token, check=${granted}`);
243
+ if (granted) break;
244
+ } else {
245
+ console.log(`[arc:modules] ${mod.name}: rule needs "${rule.token.name}", no matching token (have: ${tokenPayloads.map(t => t.tokenType).join(",")})`);
246
+ }
247
+ }
248
+
249
+ if (granted) {
250
+ filtered.push({ ...mod, url: signModuleUrl(mod.file) } as any);
251
+ }
252
+ }
253
+
254
+ console.log(`[arc:modules] Result: ${filtered.map(m => m.name).join(", ")}`);
255
+ return { modules: filtered, buildTime: manifest.buildTime };
256
+ }
257
+
147
258
  // ---------------------------------------------------------------------------
148
259
  // Platform-specific HTTP handlers
149
260
  // ---------------------------------------------------------------------------
150
261
 
151
- function staticFilesHandler(ws: WorkspaceInfo, devMode: boolean): ArcHttpHandler {
262
+ function staticFilesHandler(
263
+ ws: WorkspaceInfo,
264
+ devMode: boolean,
265
+ moduleAccessMap: Map<string, ModuleAccess>,
266
+ ): ArcHttpHandler {
152
267
  return (_req, url, ctx) => {
153
268
  const path = url.pathname;
154
269
  if (path.startsWith("/shell/"))
155
270
  return serveFile(join(ws.shellDir, path.slice(7)), ctx.corsHeaders);
156
- if (path.startsWith("/modules/"))
157
- return serveFile(join(ws.modulesDir, path.slice(9)), {
271
+ if (path.startsWith("/modules/")) {
272
+ const fileWithParams = path.slice(9);
273
+ const filename = fileWithParams.split("?")[0];
274
+ const moduleName = filename.replace(/\.js$/, "");
275
+
276
+ // Check access for protected modules
277
+ if (moduleAccessMap.has(moduleName)) {
278
+ const sig = url.searchParams.get("sig");
279
+ const exp = url.searchParams.get("exp");
280
+ if (!verifyModuleSignature(filename, sig, exp)) {
281
+ return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
282
+ }
283
+ }
284
+
285
+ return serveFile(join(ws.modulesDir, filename), {
158
286
  ...ctx.corsHeaders,
159
287
  "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
160
288
  });
289
+ }
161
290
  if (path.startsWith("/locales/"))
162
291
  return serveFile(join(ws.arcDir, path.slice(1)), ctx.corsHeaders);
163
292
  if (path === "/styles.css")
@@ -185,10 +314,20 @@ function apiEndpointsHandler(
185
314
  ws: WorkspaceInfo,
186
315
  getManifest: () => BuildManifest,
187
316
  cm: ConnectionManager | null,
317
+ moduleAccessMap: Map<string, ModuleAccess>,
188
318
  ): ArcHttpHandler {
189
- return (_req, url, ctx) => {
190
- if (url.pathname === "/api/modules")
191
- return Response.json(getManifest(), { headers: ctx.corsHeaders });
319
+ return (req, url, ctx) => {
320
+ if (url.pathname === "/api/modules") {
321
+ // Parse all tokens from X-Arc-Tokens header + Authorization fallback
322
+ const arcTokensHeader = req.headers.get("X-Arc-Tokens");
323
+ let payloads = parseArcTokensHeader(arcTokensHeader);
324
+ // Fallback: if no X-Arc-Tokens, use the single token from Authorization
325
+ if (payloads.length === 0 && ctx.tokenPayload) {
326
+ payloads = [ctx.tokenPayload];
327
+ }
328
+ return filterManifestForTokens(getManifest(), moduleAccessMap, payloads)
329
+ .then((filtered) => Response.json(filtered, { headers: ctx.corsHeaders }));
330
+ }
192
331
 
193
332
  if (url.pathname === "/api/translations") {
194
333
  const config = readTranslationsConfig(ws.rootDir);
@@ -255,10 +394,11 @@ export async function startPlatformServer(
255
394
  opts: PlatformServerOptions,
256
395
  ): Promise<PlatformServer> {
257
396
  const { ws, port, devMode, context } = opts;
397
+ const moduleAccessMap = opts.moduleAccess ?? new Map();
258
398
  let manifest = opts.manifest;
259
399
  const getManifest = () => manifest;
260
400
 
261
- const shellHtml = generateShellHtml(ws.appName, ws.manifest);
401
+ const shellHtml = generateShellHtml(ws.appName, ws.manifest, opts.arcEntries);
262
402
  const sseClients = new Set<ReadableStreamDefaultController>();
263
403
 
264
404
  if (!context) {
@@ -267,7 +407,7 @@ export async function startPlatformServer(
267
407
  "Access-Control-Allow-Origin": "*",
268
408
  "Access-Control-Allow-Methods":
269
409
  "GET, POST, PUT, DELETE, PATCH, OPTIONS",
270
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
410
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Arc-Tokens",
271
411
  };
272
412
 
273
413
  const server = Bun.serve({
@@ -285,9 +425,9 @@ export async function startPlatformServer(
285
425
 
286
426
  // Platform handlers only
287
427
  const handlers: ArcHttpHandler[] = [
288
- apiEndpointsHandler(ws, getManifest, null),
428
+ apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
289
429
  ...(devMode ? [devReloadHandler(sseClients)] : []),
290
- staticFilesHandler(ws, !!devMode),
430
+ staticFilesHandler(ws, !!devMode, moduleAccessMap),
291
431
  spaFallbackHandler(shellHtml),
292
432
  ];
293
433
 
@@ -331,9 +471,9 @@ export async function startPlatformServer(
331
471
  port,
332
472
  httpHandlers: [
333
473
  // Platform-specific handlers (checked AFTER arc handlers)
334
- apiEndpointsHandler(ws, getManifest, null),
474
+ apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
335
475
  ...(devMode ? [devReloadHandler(sseClients)] : []),
336
- staticFilesHandler(ws, !!devMode),
476
+ staticFilesHandler(ws, !!devMode, moduleAccessMap),
337
477
  spaFallbackHandler(shellHtml),
338
478
  ],
339
479
  onWsClose: (clientId) => cleanupClientSubs(clientId),