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.
- package/dist/index.js +159 -21
- 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
|
-
|
|
987
|
-
|
|
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
|
|
5125
|
-
//
|
|
5126
|
-
//
|
|
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
|
-
|
|
5164
|
-
|
|
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
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
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
|
|
5594
|
+
return this.metaFile;
|
|
5573
5595
|
}
|
|
5574
5596
|
blobPath(name) {
|
|
5575
|
-
return join(this.
|
|
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 =
|
|
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 =
|
|
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,
|
|
5831
|
+
sendJson(res, 200, kv.put(collection, key, payload.value));
|
|
5738
5832
|
return;
|
|
5739
5833
|
}
|
|
5740
5834
|
if (req.method === "DELETE") {
|
|
5741
|
-
|
|
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:
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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) {
|