railcode 0.1.21 → 0.1.22

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 (2) hide show
  1. package/dist/index.js +159 -21
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -983,13 +983,27 @@ declare const me: () => Promise<Me>;
983
983
  declare const appUsers: () => Promise<AppUser[]>;
984
984
  declare const roles: () => Promise<OrgRole[]>;
985
985
  declare const designSystem: () => Promise<string>;
986
- declare const db: { collection<T = unknown>(name: string): Collection<T> };
987
- declare const files: {
986
+ type DbNamespace = { collection<T = unknown>(name: string): Collection<T> };
987
+ type FilesNamespace = {
988
988
  upload(name: string, data: Blob | ArrayBuffer | ArrayBufferView, contentType?: string): Promise<FileMeta>;
989
989
  url(name: string): string;
990
990
  list(): Promise<FileMeta[]>;
991
991
  delete(name: string): Promise<void>;
992
992
  };
993
+ // Storage scopes. Bare db/files are aliases for \`.shared\` (app-wide). \`.user\` is the
994
+ // signed-in caller's own private namespace; \`.role(uuid)\` is one org role's namespace
995
+ // (the caller must be a live member of that role — owner/admin may reach any). The same
996
+ // (collection,key) / file name never collides across scopes.
997
+ declare const db: DbNamespace & {
998
+ shared: DbNamespace;
999
+ user: DbNamespace;
1000
+ role(uuid: string): DbNamespace;
1001
+ };
1002
+ declare const files: FilesNamespace & {
1003
+ shared: FilesNamespace;
1004
+ user: FilesNamespace;
1005
+ role(uuid: string): FilesNamespace;
1006
+ };
993
1007
  declare const data: DatabaseNamespace;
994
1008
  declare const postgres: DatabaseNamespace;
995
1009
  declare const bigquery: DatabaseNamespace;
@@ -5121,9 +5135,10 @@ class CliError extends Error {
5121
5135
  // dev — local development server (`railcode dev`)
5122
5136
  //
5123
5137
  // Serves the app on a single loopback origin and emulates the /_api/* data plane:
5124
- // identity is synthetic, KV + files live on local disk. llm/sql/queries/connectors/email
5125
- // proxy to the real instance as the signed-in user (their token + org grants) via the
5126
- // org/user-scoped routes no deploy needed. Mirrors railcode-core's dev.
5138
+ // identity is synthetic, KV + files live on local disk (isolated per storage scope —
5139
+ // shared/user/role so `db.user`/`files.role(uuid)` behave like prod). llm/sql/queries/
5140
+ // connectors/email proxy to the real instance as the signed-in user (their token + org
5141
+ // grants) via the org/user-scoped routes — no deploy needed. Mirrors railcode-core's dev.
5127
5142
  // ---------------------------------------------------------------------------
5128
5143
  const DEFAULT_DEV_PORT = 7331;
5129
5144
  const DEFAULT_ASSET_PORT = 5173;
@@ -5160,8 +5175,9 @@ async function commandDev(args) {
5160
5175
  app: manifest.app,
5161
5176
  root: plan.root,
5162
5177
  sdkPath,
5163
- kv: new DevKvStore(join(stateDir, "kv.json")),
5164
- files: new DevFileStore(stateDir),
5178
+ stateDir,
5179
+ kvStores: new Map(),
5180
+ fileStores: new Map(),
5165
5181
  port: requestedPort,
5166
5182
  config,
5167
5183
  };
@@ -5564,15 +5580,21 @@ class DevKvStore {
5564
5580
  }
5565
5581
  // ── local file store (bytes on disk, metadata in files.json) ──────────────────
5566
5582
  class DevFileStore {
5567
- dir;
5568
- constructor(dir) {
5569
- this.dir = dir;
5583
+ blobRoot;
5584
+ metaFile;
5585
+ // Scope-aware, mirroring the backend's physical layout: shared keeps the legacy
5586
+ // `files/` blob dir + `files.json` meta (byte-compatible with pre-scopes dev state);
5587
+ // user/role nest under a per-scope blob dir + meta so namespaces never collide.
5588
+ constructor(dir, scopeSlug) {
5589
+ this.blobRoot = scopeSlug ? join(dir, `files-${scopeSlug}`) : join(dir, "files");
5590
+ this.metaFile = scopeSlug ? join(dir, `files.${scopeSlug}.json`) : join(dir, "files.json");
5591
+ mkdirSync(this.blobRoot, { recursive: true });
5570
5592
  }
5571
5593
  metaPath() {
5572
- return join(this.dir, "files.json");
5594
+ return this.metaFile;
5573
5595
  }
5574
5596
  blobPath(name) {
5575
- return join(this.dir, "files", encodeURIComponent(name));
5597
+ return join(this.blobRoot, encodeURIComponent(name));
5576
5598
  }
5577
5599
  readMeta() {
5578
5600
  const p = this.metaPath();
@@ -5675,7 +5697,79 @@ async function handleLocalApi(ctx, req, res, url) {
5675
5697
  }
5676
5698
  sendJson(res, 404, { detail: "not found" });
5677
5699
  }
5700
+ // role/user selectors are UUIDs on the backend (it resolves them to FK ids). Enforce
5701
+ // the same here so a selector can NEVER reach the filesystem as a path segment — the
5702
+ // slug feeds kv.<slug>.json / files-<slug>/, so an unvalidated "../.." would traverse
5703
+ // out of the dev state dir. UUIDs contain only hex + hyphens, so this closes it.
5704
+ const DEV_UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
5705
+ function resolveDevScope(url) {
5706
+ const scope = (url.searchParams.get("scope") || "shared").toLowerCase();
5707
+ const role = url.searchParams.get("role");
5708
+ const user = url.searchParams.get("user");
5709
+ if (scope === "shared") {
5710
+ if (role || user) {
5711
+ return { ok: false, status: 400, detail: "shared scope takes no role or user selector" };
5712
+ }
5713
+ return { ok: true, scope: { key: "shared", slug: "shared" } };
5714
+ }
5715
+ if (scope === "user") {
5716
+ if (role)
5717
+ return { ok: false, status: 400, detail: "user scope takes no role selector" };
5718
+ if (user !== null && !DEV_UUID_RE.test(user)) {
5719
+ return { ok: false, status: 400, detail: "malformed user id" };
5720
+ }
5721
+ const uuid = user || DEV_USER_UUID;
5722
+ return { ok: true, scope: { key: `user:${uuid}`, slug: `users-${uuid}` } };
5723
+ }
5724
+ if (scope === "role") {
5725
+ if (user)
5726
+ return { ok: false, status: 400, detail: "role scope takes no user selector" };
5727
+ if (!role)
5728
+ return { ok: false, status: 400, detail: "role scope requires a role id" };
5729
+ if (!DEV_UUID_RE.test(role))
5730
+ return { ok: false, status: 400, detail: "malformed role id" };
5731
+ return { ok: true, scope: { key: `role:${role}`, slug: `roles-${role}` } };
5732
+ }
5733
+ return { ok: false, status: 400, detail: `invalid scope: ${scope}` };
5734
+ }
5735
+ function kvForScope(ctx, scope) {
5736
+ let store = ctx.kvStores.get(scope.key);
5737
+ if (!store) {
5738
+ const path = scope.key === "shared"
5739
+ ? join(ctx.stateDir, "kv.json")
5740
+ : join(ctx.stateDir, `kv.${scope.slug}.json`);
5741
+ store = new DevKvStore(path);
5742
+ ctx.kvStores.set(scope.key, store);
5743
+ }
5744
+ return store;
5745
+ }
5746
+ function filesForScope(ctx, scope) {
5747
+ let store = ctx.fileStores.get(scope.key);
5748
+ if (!store) {
5749
+ store = new DevFileStore(ctx.stateDir, scope.key === "shared" ? undefined : scope.slug);
5750
+ ctx.fileStores.set(scope.key, store);
5751
+ }
5752
+ return store;
5753
+ }
5754
+ // Re-attach the request's scope selectors to a same-origin /_api URL (the local-proxy
5755
+ // blob upload URL, download URLs) so the follow-up call resolves the identical scope —
5756
+ // mirrors the backend's _scoped_url.
5757
+ function scopeQuerySuffix(url) {
5758
+ const parts = [];
5759
+ for (const k of ["scope", "role", "user"]) {
5760
+ const v = url.searchParams.get(k);
5761
+ if (v)
5762
+ parts.push(`${k}=${encodeURIComponent(v)}`);
5763
+ }
5764
+ return parts.length ? `&${parts.join("&")}` : "";
5765
+ }
5678
5766
  async function handleKv(ctx, req, res, path, url) {
5767
+ const resolved = resolveDevScope(url);
5768
+ if (!resolved.ok) {
5769
+ sendJson(res, resolved.status, { detail: resolved.detail });
5770
+ return;
5771
+ }
5772
+ const kv = kvForScope(ctx, resolved.scope);
5679
5773
  // path: /kv/{collection} | /kv/{collection}/{key...}
5680
5774
  const rest = path.slice("/kv/".length);
5681
5775
  const slash = rest.indexOf("/");
@@ -5695,7 +5789,7 @@ async function handleKv(ctx, req, res, path, url) {
5695
5789
  return;
5696
5790
  }
5697
5791
  try {
5698
- const records = ctx.kv.records(collection);
5792
+ const records = kv.records(collection);
5699
5793
  const params = parseKvParams(url);
5700
5794
  if (url.searchParams.get("count") === "true") {
5701
5795
  sendJson(res, 200, { count: kvCount(records, params) });
@@ -5713,7 +5807,7 @@ async function handleKv(ctx, req, res, path, url) {
5713
5807
  return;
5714
5808
  }
5715
5809
  if (req.method === "GET") {
5716
- const rec = ctx.kv.get(collection, key);
5810
+ const rec = kv.get(collection, key);
5717
5811
  if (!rec)
5718
5812
  sendJson(res, 404, { detail: "Key not found" });
5719
5813
  else
@@ -5734,11 +5828,11 @@ async function handleKv(ctx, req, res, path, url) {
5734
5828
  sendJson(res, 413, { detail: "Value too large" });
5735
5829
  return;
5736
5830
  }
5737
- sendJson(res, 200, ctx.kv.put(collection, key, payload.value));
5831
+ sendJson(res, 200, kv.put(collection, key, payload.value));
5738
5832
  return;
5739
5833
  }
5740
5834
  if (req.method === "DELETE") {
5741
- ctx.kv.delete(collection, key);
5835
+ kv.delete(collection, key);
5742
5836
  sendStatus(res, 204);
5743
5837
  return;
5744
5838
  }
@@ -5776,8 +5870,44 @@ function parseKvParams(url) {
5776
5870
  };
5777
5871
  }
5778
5872
  async function handleFiles(ctx, req, res, path, url) {
5873
+ const resolved = resolveDevScope(url);
5874
+ if (!resolved.ok) {
5875
+ sendJson(res, resolved.status, { detail: resolved.detail });
5876
+ return;
5877
+ }
5878
+ const files = filesForScope(ctx, resolved.scope);
5879
+ const scopeSuffix = scopeQuerySuffix(url);
5779
5880
  if (path === "/files" && req.method === "GET") {
5780
- sendJson(res, 200, { items: ctx.files.list() });
5881
+ sendJson(res, 200, { items: files.list() });
5882
+ return;
5883
+ }
5884
+ if (path === "/files/urls" && req.method === "POST") {
5885
+ const body = await readBody(req);
5886
+ let payload;
5887
+ try {
5888
+ payload = JSON.parse(body.toString("utf8") || "{}");
5889
+ }
5890
+ catch {
5891
+ sendJson(res, 400, { detail: "invalid JSON body" });
5892
+ return;
5893
+ }
5894
+ const names = Array.isArray(payload.names)
5895
+ ? payload.names.filter((n) => typeof n === "string")
5896
+ : [];
5897
+ // Local proxy: download URLs carry the scope so a later GET re-resolves it.
5898
+ const scopeQuery = scopeSuffix ? `?${scopeSuffix.slice(1)}` : "";
5899
+ const items = [];
5900
+ const missing = [];
5901
+ for (const name of [...new Set(names)]) {
5902
+ const safe = devSafeName(name);
5903
+ if (safe && files.get(safe)) {
5904
+ items.push({ name, url: `/_api/files/${encodeURIComponent(safe)}${scopeQuery}`, expires_in: null });
5905
+ }
5906
+ else {
5907
+ missing.push(name);
5908
+ }
5909
+ }
5910
+ sendJson(res, 200, { items, missing });
5781
5911
  return;
5782
5912
  }
5783
5913
  if (path === "/files" && req.method === "POST") {
@@ -5795,10 +5925,12 @@ async function handleFiles(ctx, req, res, path, url) {
5795
5925
  sendJson(res, 400, { detail: "Invalid file name" });
5796
5926
  return;
5797
5927
  }
5928
+ // The blob URL carries the scope selectors so the follow-up PUT lands in the
5929
+ // same scope (mirrors the backend's _scoped_url on the proxy path).
5798
5930
  sendJson(res, 200, {
5799
5931
  mode: "proxy",
5800
5932
  method: "PUT",
5801
- url: `/_api/files/blob?name=${encodeURIComponent(safe)}`,
5933
+ url: `/_api/files/blob?name=${encodeURIComponent(safe)}${scopeSuffix}`,
5802
5934
  object_key: safe,
5803
5935
  headers: {},
5804
5936
  expires_in: null,
@@ -5817,7 +5949,7 @@ async function handleFiles(ctx, req, res, path, url) {
5817
5949
  return;
5818
5950
  }
5819
5951
  const contentType = req.headers["content-type"] || "application/octet-stream";
5820
- sendJson(res, 200, ctx.files.put(safe, body, contentType));
5952
+ sendJson(res, 200, files.put(safe, body, contentType));
5821
5953
  return;
5822
5954
  }
5823
5955
  if (path.startsWith("/files/")) {
@@ -5827,7 +5959,7 @@ async function handleFiles(ctx, req, res, path, url) {
5827
5959
  return;
5828
5960
  }
5829
5961
  if (req.method === "GET") {
5830
- const found = ctx.files.get(name);
5962
+ const found = files.get(name);
5831
5963
  if (!found) {
5832
5964
  sendJson(res, 404, { detail: "File not found" });
5833
5965
  return;
@@ -5844,7 +5976,7 @@ async function handleFiles(ctx, req, res, path, url) {
5844
5976
  return;
5845
5977
  }
5846
5978
  if (req.method === "DELETE") {
5847
- ctx.files.delete(name);
5979
+ files.delete(name);
5848
5980
  sendStatus(res, 204);
5849
5981
  return;
5850
5982
  }
@@ -6042,6 +6174,12 @@ function devSafeName(name) {
6042
6174
  return null;
6043
6175
  if (name.split("/").some((seg) => seg === "" || seg === "." || seg === ".."))
6044
6176
  return null;
6177
+ // `users`/`roles` are reserved as the first path segment (mirrors the backend's
6178
+ // _safe_name): the user/role file subtrees nest under the shared prefix, so a
6179
+ // shared-scope name beginning with them would alias another scope's object.
6180
+ const first = name.split("/", 1)[0].toLowerCase();
6181
+ if (first === "users" || first === "roles")
6182
+ return null;
6045
6183
  return name;
6046
6184
  }
6047
6185
  function readBody(req) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "railcode",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Developer CLI for the multi-tenant Railcode platform: log in, scaffold, and deploy static apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",