railcode 0.1.21 → 0.1.23

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 CHANGED
@@ -16,6 +16,9 @@ import { QueryCmdError, paramSignature, parseParamDecls, parseRunParams, pickAut
16
16
  import { APP_MANIFEST_NAME, ManifestParseError, documentOperations, parseManifestYaml, renderManifestDeployOutcome, renderManifestSummary, } from "./manifest.js";
17
17
  import { ConnectorError, buildConnectorRequest, connectorOpenapiOutput, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderConnectorDocs, renderProxyResponse, } from "./connectors.js";
18
18
  import { llmModelTable, llmProviderTable } from "./llm.js";
19
+ import { OrgAdminError, appTable, parseAccessMode, parseAssignableRole, parseGrantSubject, parseMemberList, parseResourceIds, parseResourceType, grantSubjectLabel, grantTable, memberTable, renderAppAccess, renderEffective, resolveAppRef, resolveMemberRef, resolveRoleRef, roleTable, } from "./orgadmin.js";
20
+ import { ObservabilityError, buildLogQuery, logTable, parseLimit, parseRange, renderAnalytics, resolveLogStream, validateLogSource, validateLogStatus, } from "./observability.js";
21
+ import { ConnectionAdminError, buildConnectionCreateBody, buildServiceConnectorCreateBody, connectionTable, nativeConnectorTable, parseAuthType, parseConnectionEngine, parseJsonObject, parseMethodList, resolveConnectionRef, resolveServiceConnectorRef, serviceConnectorAdminTable, } from "./connections.js";
19
22
  // ---------------------------------------------------------------------------
20
23
  // Constants
21
24
  // ---------------------------------------------------------------------------
@@ -40,11 +43,20 @@ Usage:
40
43
  railcode design-system Print your org's design-system guidance (markdown)
41
44
  railcode db <list|query> ... List data connectors / run read-only SQL
42
45
  railcode query <list|run|create|update|delete> ... Saved queries: invoke by name / author (admin)
43
- railcode connector <list|docs|fetch> ... List connectors / show API docs / proxy a call
46
+ railcode connector <list|docs|fetch|create|native|enable|delete> ...
47
+ Service connectors: call them, or manage them (admin)
48
+ railcode connections <list|create|delete> ... Manage org data connectors (admin)
44
49
  railcode llm <providers|models> List the LLM providers/models apps can call
45
50
  railcode manifest <validate|show> ... Validate ${APP_MANIFEST_NAME} locally / show an app's ratified manifest
46
51
  railcode agent <list|show|create|update|delete|run|schedule> ...
47
52
  Manage org-scoped managed agents
53
+ railcode members <list|set-role|remove|add> ... Org members + system roles (list: any; mutations: admin)
54
+ railcode roles <list|create|update|delete|add-member|remove-member|grants|grant|revoke|materialize|effective|catalog> ...
55
+ Manage org roles + the granular grants table (admin)
56
+ railcode apps <list|show|access|set-access|transfer|delete> ... Manage apps + access policy
57
+ railcode analytics <app> [--range 7d|30d|90d] Per-app pageview analytics (admin/owner)
58
+ railcode logs <connector|service-connector|llm|email|agent> [filters]
59
+ Read org observability logs (admin)
48
60
  railcode --version
49
61
  railcode --help
50
62
 
@@ -93,9 +105,32 @@ async function main() {
93
105
  case "connectors":
94
106
  await commandConnector(args);
95
107
  return;
108
+ case "connection":
109
+ case "connections":
110
+ await commandConnections(args);
111
+ return;
96
112
  case "llm":
97
113
  await commandLlm(args);
98
114
  return;
115
+ case "member":
116
+ case "members":
117
+ await commandMembers(args);
118
+ return;
119
+ case "role":
120
+ case "roles":
121
+ await commandRoles(args);
122
+ return;
123
+ case "app":
124
+ case "apps":
125
+ await commandApps(args);
126
+ return;
127
+ case "analytics":
128
+ await commandAnalytics(args);
129
+ return;
130
+ case "logs":
131
+ case "log":
132
+ await commandLogs(args);
133
+ return;
99
134
  case "manifest":
100
135
  await commandManifest(args);
101
136
  return;
@@ -983,13 +1018,27 @@ declare const me: () => Promise<Me>;
983
1018
  declare const appUsers: () => Promise<AppUser[]>;
984
1019
  declare const roles: () => Promise<OrgRole[]>;
985
1020
  declare const designSystem: () => Promise<string>;
986
- declare const db: { collection<T = unknown>(name: string): Collection<T> };
987
- declare const files: {
1021
+ type DbNamespace = { collection<T = unknown>(name: string): Collection<T> };
1022
+ type FilesNamespace = {
988
1023
  upload(name: string, data: Blob | ArrayBuffer | ArrayBufferView, contentType?: string): Promise<FileMeta>;
989
1024
  url(name: string): string;
990
1025
  list(): Promise<FileMeta[]>;
991
1026
  delete(name: string): Promise<void>;
992
1027
  };
1028
+ // Storage scopes. Bare db/files are aliases for \`.shared\` (app-wide). \`.user\` is the
1029
+ // signed-in caller's own private namespace; \`.role(uuid)\` is one org role's namespace
1030
+ // (the caller must be a live member of that role — owner/admin may reach any). The same
1031
+ // (collection,key) / file name never collides across scopes.
1032
+ declare const db: DbNamespace & {
1033
+ shared: DbNamespace;
1034
+ user: DbNamespace;
1035
+ role(uuid: string): DbNamespace;
1036
+ };
1037
+ declare const files: FilesNamespace & {
1038
+ shared: FilesNamespace;
1039
+ user: FilesNamespace;
1040
+ role(uuid: string): FilesNamespace;
1041
+ };
993
1042
  declare const data: DatabaseNamespace;
994
1043
  declare const postgres: DatabaseNamespace;
995
1044
  declare const bigquery: DatabaseNamespace;
@@ -4686,9 +4735,20 @@ Usage:
4686
4735
  railcode connector fetch <path> --connector <name> [opts]
4687
4736
  Proxy one HTTP call through a connector
4688
4737
 
4738
+ Admin (service-connector:manage):
4739
+ railcode connector native List native presets + enabled state
4740
+ railcode connector enable <key> --credentials '{...}'
4741
+ One-click enable/rotate a native preset
4742
+ railcode connector create --name <n> --base-url <url> [--auth-type <t>]
4743
+ [--auth-config '{...}'] [--methods GET,POST] [--description <d>] [--disabled]
4744
+ Create/replace a custom connector
4745
+ railcode connector delete <name|uuid> Delete a service connector
4746
+
4689
4747
  Aliases: connector = connectors; list = ls, connectors; docs = doc; fetch = request.
4690
4748
 
4691
4749
  List options:
4750
+ --admin Manage view: base_url, credential + enabled
4751
+ state, native key (service-connector:manage)
4692
4752
  --json Print the raw connector array
4693
4753
 
4694
4754
  Docs options:
@@ -4723,6 +4783,19 @@ async function commandConnector(args) {
4723
4783
  case "request":
4724
4784
  await commandConnectorFetch(args);
4725
4785
  return;
4786
+ case "native":
4787
+ await commandConnectorNative(args);
4788
+ return;
4789
+ case "enable":
4790
+ await commandConnectorEnable(args);
4791
+ return;
4792
+ case "create":
4793
+ await commandConnectorCreate(args);
4794
+ return;
4795
+ case "delete":
4796
+ case "rm":
4797
+ await commandConnectorDelete(args);
4798
+ return;
4726
4799
  case "":
4727
4800
  case "help":
4728
4801
  console.log(CONNECTOR_HELP);
@@ -4733,17 +4806,98 @@ async function commandConnector(args) {
4733
4806
  }
4734
4807
  catch (error) {
4735
4808
  // Validation helpers throw ConnectorError; surface them as usage errors (exit 2).
4736
- if (error instanceof ConnectorError)
4809
+ if (error instanceof ConnectorError || error instanceof ConnectionAdminError) {
4737
4810
  throw new CliError(error.message, 2);
4811
+ }
4738
4812
  throw error;
4739
4813
  }
4740
4814
  }
4741
- async function commandConnectorList(args) {
4815
+ // Admin list of service connectors (with uuids, for delete/update) — distinct from
4816
+ // the member-facing `connector list`, which reads the caller-scoped data plane.
4817
+ async function fetchServiceConnectorsAdmin(config) {
4818
+ return (await authedJson(config, `/api/organizations/${config.orgUuid}/service-connectors`, "List service connectors"));
4819
+ }
4820
+ async function commandConnectorNative(args) {
4742
4821
  assertKnownConnectorOptions(args, ["json", "apiUrl"]);
4822
+ const config = requireLogin(args);
4823
+ const natives = (await authedJson(config, `/api/organizations/${config.orgUuid}/service-connectors/native`, "List native presets"));
4824
+ if (args.options.json) {
4825
+ console.log(JSON.stringify(natives, null, 2));
4826
+ return;
4827
+ }
4828
+ console.log(nativeConnectorTable(natives));
4829
+ }
4830
+ async function commandConnectorEnable(args) {
4831
+ assertKnownConnectorOptions(args, ["credentials", "credentialsFile", "apiUrl"]);
4832
+ const key = args.rest[1];
4833
+ if (!key) {
4834
+ throw new CliError("Usage: railcode connector enable <key> --credentials '{...}'. See `railcode connector native`.", 2);
4835
+ }
4836
+ const credentials = readJsonObjectOption(args, "credentials", "credentialsFile", "--credentials");
4837
+ const config = requireLogin(args);
4838
+ const created = (await authedJson(config, `/api/organizations/${config.orgUuid}/service-connectors/native/${encodeURIComponent(key)}/enable`, `Enable native connector "${key}"`, jsonBody({ credentials })));
4839
+ console.log(`Native connector "${created.name}" enabled.`);
4840
+ }
4841
+ async function commandConnectorCreate(args) {
4842
+ assertKnownConnectorOptions(args, [
4843
+ "name",
4844
+ "baseUrl",
4845
+ "authType",
4846
+ "authConfig",
4847
+ "authConfigFile",
4848
+ "description",
4849
+ "methods",
4850
+ "disabled",
4851
+ "apiUrl",
4852
+ ]);
4853
+ const authType = parseAuthType(optionString(args, "authType"));
4854
+ const authConfig = authType === "none"
4855
+ ? {}
4856
+ : readJsonObjectOption(args, "authConfig", "authConfigFile", "--auth-config");
4857
+ const body = buildServiceConnectorCreateBody({
4858
+ name: optionString(args, "name"),
4859
+ baseUrl: optionString(args, "baseUrl"),
4860
+ authType,
4861
+ authConfig,
4862
+ description: optionString(args, "description"),
4863
+ allowedMethods: parseMethodList(optionString(args, "methods")),
4864
+ enabled: args.options.disabled !== true,
4865
+ });
4866
+ const config = requireLogin(args);
4867
+ const created = (await authedJson(config, `/api/organizations/${config.orgUuid}/service-connectors`, "Create service connector", jsonBody(body)));
4868
+ console.log(`Service connector "${created.name}" → ${created.base_url}.`);
4869
+ }
4870
+ async function commandConnectorDelete(args) {
4871
+ assertKnownConnectorOptions(args, ["apiUrl"]);
4872
+ const ref = args.rest[1];
4873
+ if (!ref)
4874
+ throw new CliError("Usage: railcode connector delete <name|uuid>", 2);
4875
+ const config = requireLogin(args);
4876
+ const conn = resolveServiceConnectorRef(await fetchServiceConnectorsAdmin(config), ref);
4877
+ await authedNoContent(config, `/api/organizations/${config.orgUuid}/service-connectors/${conn.uuid}`, `Delete service connector "${conn.name}"`, { method: "DELETE" });
4878
+ console.log(`Service connector "${conn.name}" deleted.`);
4879
+ }
4880
+ async function commandConnectorList(args) {
4881
+ assertKnownConnectorOptions(args, ["json", "admin", "apiUrl"]);
4743
4882
  if (args.rest.length > 1) {
4744
4883
  throw new CliError(`railcode connector list takes no arguments (got "${args.rest[1]}").`, 2);
4745
4884
  }
4746
4885
  const config = requireLogin(args);
4886
+ // --admin: the manage view (base_url, credential + enabled state, native key),
4887
+ // gated by service-connector:manage. Default is the caller-facing list.
4888
+ if (args.options.admin === true) {
4889
+ const connectors = await fetchServiceConnectorsAdmin(config);
4890
+ if (args.options.json) {
4891
+ console.log(JSON.stringify(connectors, null, 2));
4892
+ return;
4893
+ }
4894
+ if (connectors.length === 0) {
4895
+ console.log("No service connectors configured for this organization.");
4896
+ return;
4897
+ }
4898
+ console.log(serviceConnectorAdminTable(connectors));
4899
+ return;
4900
+ }
4747
4901
  const connectors = await fetchServiceConnectors(config);
4748
4902
  if (args.options.json) {
4749
4903
  console.log(JSON.stringify(connectors, null, 2));
@@ -4940,6 +5094,859 @@ function assertKnownLlmOptions(args, allowed) {
4940
5094
  }
4941
5095
  }
4942
5096
  // ---------------------------------------------------------------------------
5097
+ // Shared admin helpers (members / roles / apps / analytics / logs / connections)
5098
+ //
5099
+ // All of these hit the org-scoped `/api/organizations/{org}/...` management APIs
5100
+ // with the personal token. They mirror the web console's admin surfaces so an
5101
+ // operator can do everything from the CLI. Most are owner/admin-gated server-side
5102
+ // (403 for plain members); a couple (apps list/show/access of your own apps) work
5103
+ // for any member per the app access policy.
5104
+ // ---------------------------------------------------------------------------
5105
+ function rejectUnknownOptions(args, allowed, cmd, help) {
5106
+ for (const key of Object.keys(args.options)) {
5107
+ if (!allowed.includes(key)) {
5108
+ throw new CliError(`Unknown option --${camelToKebab(key)} for \`railcode ${cmd}\`.\n\n${help}`, 2);
5109
+ }
5110
+ }
5111
+ }
5112
+ // GET/POST/PATCH that returns a parsed JSON body (drops a rejected token first).
5113
+ async function authedJson(config, path, action, init = {}) {
5114
+ const resp = await authedFetch(config, path, init);
5115
+ if (resp.status === 401)
5116
+ await reLoginOrThrow();
5117
+ return readJsonOrThrow(resp, action);
5118
+ }
5119
+ // A 204/no-body mutation (DELETE, membership add/remove). Surfaces the server
5120
+ // detail on any non-2xx.
5121
+ async function authedNoContent(config, path, action, init = {}) {
5122
+ const resp = await authedFetch(config, path, init);
5123
+ if (resp.status === 401)
5124
+ await reLoginOrThrow();
5125
+ if (!resp.ok) {
5126
+ throw new CliError(`${action} failed (${resp.status}): ${await errorDetail(resp)}`, 1);
5127
+ }
5128
+ }
5129
+ function jsonBody(body) {
5130
+ return {
5131
+ method: "POST",
5132
+ headers: { "Content-Type": "application/json" },
5133
+ body: JSON.stringify(body),
5134
+ };
5135
+ }
5136
+ async function fetchMembers(config) {
5137
+ return (await authedJson(config, `/api/organizations/${config.orgUuid}/members`, "List members"));
5138
+ }
5139
+ async function fetchApps(config) {
5140
+ return (await authedJson(config, `/api/organizations/${config.orgUuid}/apps`, "List apps"));
5141
+ }
5142
+ async function fetchPermissions(config) {
5143
+ return (await authedJson(config, `/api/organizations/${config.orgUuid}/permissions`, "List roles + grants"));
5144
+ }
5145
+ // Resolve a subject spec (org | role:x | user:x) to the API's {subject_type,
5146
+ // subject_uuid} shape, resolving names/emails against the org's roles + members.
5147
+ async function resolveGrantSubject(config, spec) {
5148
+ if (spec.kind === "org")
5149
+ return { subject_type: "org", subject_uuid: null };
5150
+ if (spec.kind === "role") {
5151
+ const { roles } = await fetchPermissions(config);
5152
+ return { subject_type: "role", subject_uuid: resolveRoleRef(roles, spec.ref).uuid };
5153
+ }
5154
+ const members = await fetchMembers(config);
5155
+ return { subject_type: "user", subject_uuid: resolveMemberRef(members, spec.ref).uuid };
5156
+ }
5157
+ // ---------------------------------------------------------------------------
5158
+ // members — org members + system roles (`railcode members`)
5159
+ // ---------------------------------------------------------------------------
5160
+ const MEMBERS_HELP = `railcode members — org members + system roles
5161
+
5162
+ Usage:
5163
+ railcode members list List everyone in your org (any member)
5164
+ railcode members set-role <email|uuid> --role admin|member
5165
+ Change a member's system role (admin)
5166
+ railcode members remove <email|uuid> Detach a member from the org (admin)
5167
+ railcode members add --email <e> --name <n> --password <p> --role admin|member
5168
+ [--no-password-change] Provision a member (admin; self-hosted only)
5169
+
5170
+ list is available to any member; set-role/remove/add require admin.
5171
+
5172
+ Options:
5173
+ --json Print raw JSON (list)
5174
+ `;
5175
+ async function commandMembers(args) {
5176
+ try {
5177
+ const sub = args.rest[0] ?? "";
5178
+ switch (sub) {
5179
+ case "list":
5180
+ case "ls":
5181
+ await commandMembersList(args);
5182
+ return;
5183
+ case "set-role":
5184
+ await commandMembersSetRole(args);
5185
+ return;
5186
+ case "remove":
5187
+ case "rm":
5188
+ await commandMembersRemove(args);
5189
+ return;
5190
+ case "add":
5191
+ case "provision":
5192
+ await commandMembersAdd(args);
5193
+ return;
5194
+ case "":
5195
+ case "help":
5196
+ console.log(MEMBERS_HELP);
5197
+ return;
5198
+ default:
5199
+ throw new CliError(`Unknown members command: ${sub}\n\n${MEMBERS_HELP}`, 2);
5200
+ }
5201
+ }
5202
+ catch (error) {
5203
+ if (error instanceof OrgAdminError)
5204
+ throw new CliError(error.message, 2);
5205
+ throw error;
5206
+ }
5207
+ }
5208
+ async function commandMembersList(args) {
5209
+ rejectUnknownOptions(args, ["json", "apiUrl"], "members", MEMBERS_HELP);
5210
+ const config = requireLogin(args);
5211
+ const members = await fetchMembers(config);
5212
+ if (args.options.json) {
5213
+ console.log(JSON.stringify(members, null, 2));
5214
+ return;
5215
+ }
5216
+ if (members.length === 0) {
5217
+ console.log("No members (that shouldn't happen).");
5218
+ return;
5219
+ }
5220
+ console.log(memberTable(members));
5221
+ }
5222
+ async function commandMembersSetRole(args) {
5223
+ rejectUnknownOptions(args, ["role", "apiUrl"], "members", MEMBERS_HELP);
5224
+ const ref = args.rest[1];
5225
+ if (!ref)
5226
+ throw new CliError("Usage: railcode members set-role <email|uuid> --role admin|member", 2);
5227
+ const role = parseAssignableRole(optionString(args, "role"));
5228
+ const config = requireLogin(args);
5229
+ const member = resolveMemberRef(await fetchMembers(config), ref);
5230
+ const updated = (await authedJson(config, `/api/organizations/${config.orgUuid}/members/${member.uuid}`, "Update member role", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role }) }));
5231
+ console.log(`${updated.email} is now ${updated.role}.`);
5232
+ }
5233
+ async function commandMembersRemove(args) {
5234
+ rejectUnknownOptions(args, ["apiUrl"], "members", MEMBERS_HELP);
5235
+ const ref = args.rest[1];
5236
+ if (!ref)
5237
+ throw new CliError("Usage: railcode members remove <email|uuid>", 2);
5238
+ const config = requireLogin(args);
5239
+ const member = resolveMemberRef(await fetchMembers(config), ref);
5240
+ await authedNoContent(config, `/api/organizations/${config.orgUuid}/members/${member.uuid}`, `Remove member ${member.email}`, { method: "DELETE" });
5241
+ console.log(`Removed ${member.email} from the organization.`);
5242
+ }
5243
+ async function commandMembersAdd(args) {
5244
+ rejectUnknownOptions(args, ["email", "name", "password", "role", "noPasswordChange", "apiUrl"], "members", MEMBERS_HELP);
5245
+ const email = optionString(args, "email");
5246
+ const name = optionString(args, "name");
5247
+ const password = optionString(args, "password");
5248
+ const roleOpt = optionString(args, "role");
5249
+ if (!email || !name || !password || !roleOpt) {
5250
+ throw new CliError("railcode members add requires --email, --name, --password and --role.\n\n" + MEMBERS_HELP, 2);
5251
+ }
5252
+ const role = parseAssignableRole(roleOpt);
5253
+ const body = { email, name, password, role };
5254
+ if (args.options.noPasswordChange === true)
5255
+ body.must_change_password = false;
5256
+ const config = requireLogin(args);
5257
+ const created = (await authedJson(config, `/api/organizations/${config.orgUuid}/members`, "Provision member", jsonBody(body)));
5258
+ console.log(`Provisioned ${created.email} (${created.role}).`);
5259
+ }
5260
+ // ---------------------------------------------------------------------------
5261
+ // roles — org roles + the granular grants table (`railcode roles`)
5262
+ // ---------------------------------------------------------------------------
5263
+ const ROLES_HELP = `railcode roles — org roles + the granular grants table (admin)
5264
+
5265
+ Roles:
5266
+ railcode roles list List org roles (with member counts)
5267
+ railcode roles create --name <n> [--description <d>]
5268
+ railcode roles update <role> [--name <n>] [--description <d>]
5269
+ railcode roles delete <role>
5270
+ railcode roles add-member <role> <email|uuid>
5271
+ railcode roles remove-member <role> <email|uuid>
5272
+
5273
+ Grants (additive permissions):
5274
+ railcode roles grants List every grant row
5275
+ railcode roles grant --subject <org|role:X|user:X> --resource <type> --ids <a,b>
5276
+ railcode roles revoke <grant_id>
5277
+ railcode roles materialize --subject <...> --resource <type>
5278
+ Expand a "*" wildcard into explicit rows
5279
+ railcode roles effective <email|uuid> A member's computed effective access
5280
+ railcode roles catalog Grantable resources (apps/connectors/…)
5281
+
5282
+ <role> is a role name or UUID. --resource ∈ app|llm|email|sc_endpoint|connector|saved_query|agent.
5283
+
5284
+ Options:
5285
+ --json Print raw JSON (list/grants/catalog)
5286
+ `;
5287
+ async function commandRoles(args) {
5288
+ try {
5289
+ const sub = args.rest[0] ?? "";
5290
+ switch (sub) {
5291
+ case "list":
5292
+ case "ls":
5293
+ await commandRolesList(args);
5294
+ return;
5295
+ case "create":
5296
+ await commandRolesCreate(args);
5297
+ return;
5298
+ case "update":
5299
+ await commandRolesUpdate(args);
5300
+ return;
5301
+ case "delete":
5302
+ case "rm":
5303
+ await commandRolesDelete(args);
5304
+ return;
5305
+ case "add-member":
5306
+ await commandRolesMember(args, true);
5307
+ return;
5308
+ case "remove-member":
5309
+ await commandRolesMember(args, false);
5310
+ return;
5311
+ case "grants":
5312
+ await commandRolesGrants(args);
5313
+ return;
5314
+ case "grant":
5315
+ await commandRolesGrant(args);
5316
+ return;
5317
+ case "revoke":
5318
+ await commandRolesRevoke(args);
5319
+ return;
5320
+ case "materialize":
5321
+ await commandRolesMaterialize(args);
5322
+ return;
5323
+ case "effective":
5324
+ await commandRolesEffective(args);
5325
+ return;
5326
+ case "catalog":
5327
+ await commandRolesCatalog(args);
5328
+ return;
5329
+ case "":
5330
+ case "help":
5331
+ console.log(ROLES_HELP);
5332
+ return;
5333
+ default:
5334
+ throw new CliError(`Unknown roles command: ${sub}\n\n${ROLES_HELP}`, 2);
5335
+ }
5336
+ }
5337
+ catch (error) {
5338
+ if (error instanceof OrgAdminError)
5339
+ throw new CliError(error.message, 2);
5340
+ throw error;
5341
+ }
5342
+ }
5343
+ async function commandRolesList(args) {
5344
+ rejectUnknownOptions(args, ["json", "apiUrl"], "roles", ROLES_HELP);
5345
+ const config = requireLogin(args);
5346
+ const { roles } = await fetchPermissions(config);
5347
+ if (args.options.json) {
5348
+ console.log(JSON.stringify(roles, null, 2));
5349
+ return;
5350
+ }
5351
+ if (roles.length === 0) {
5352
+ console.log("No org roles defined. Create one: railcode roles create --name <name>.");
5353
+ return;
5354
+ }
5355
+ console.log(roleTable(roles));
5356
+ }
5357
+ async function commandRolesCreate(args) {
5358
+ rejectUnknownOptions(args, ["name", "description", "apiUrl"], "roles", ROLES_HELP);
5359
+ const name = optionString(args, "name");
5360
+ if (!name)
5361
+ throw new CliError("railcode roles create requires --name.", 2);
5362
+ const config = requireLogin(args);
5363
+ const role = (await authedJson(config, `/api/organizations/${config.orgUuid}/roles`, "Create role", jsonBody({ name, description: optionString(args, "description") ?? "" })));
5364
+ console.log(`Role "${role.name}" created (${role.uuid}).`);
5365
+ }
5366
+ async function commandRolesUpdate(args) {
5367
+ rejectUnknownOptions(args, ["name", "description", "apiUrl"], "roles", ROLES_HELP);
5368
+ const ref = args.rest[1];
5369
+ if (!ref)
5370
+ throw new CliError("Usage: railcode roles update <role> [--name <n>] [--description <d>]", 2);
5371
+ const body = {};
5372
+ const name = optionString(args, "name");
5373
+ const description = optionString(args, "description");
5374
+ if (name !== undefined)
5375
+ body.name = name;
5376
+ if (description !== undefined)
5377
+ body.description = description;
5378
+ if (Object.keys(body).length === 0) {
5379
+ throw new CliError("Nothing to update. Pass --name and/or --description.", 2);
5380
+ }
5381
+ const config = requireLogin(args);
5382
+ const role = resolveRoleRef((await fetchPermissions(config)).roles, ref);
5383
+ const updated = (await authedJson(config, `/api/organizations/${config.orgUuid}/roles/${role.uuid}`, "Update role", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }));
5384
+ console.log(`Role "${updated.name}" updated.`);
5385
+ }
5386
+ async function commandRolesDelete(args) {
5387
+ rejectUnknownOptions(args, ["apiUrl"], "roles", ROLES_HELP);
5388
+ const ref = args.rest[1];
5389
+ if (!ref)
5390
+ throw new CliError("Usage: railcode roles delete <role>", 2);
5391
+ const config = requireLogin(args);
5392
+ const role = resolveRoleRef((await fetchPermissions(config)).roles, ref);
5393
+ await authedNoContent(config, `/api/organizations/${config.orgUuid}/roles/${role.uuid}`, `Delete role "${role.name}"`, { method: "DELETE" });
5394
+ console.log(`Role "${role.name}" deleted (its memberships + grants were removed too).`);
5395
+ }
5396
+ async function commandRolesMember(args, add) {
5397
+ rejectUnknownOptions(args, ["apiUrl"], "roles", ROLES_HELP);
5398
+ const roleRef = args.rest[1];
5399
+ const userRef = args.rest[2];
5400
+ if (!roleRef || !userRef) {
5401
+ throw new CliError(`Usage: railcode roles ${add ? "add-member" : "remove-member"} <role> <email|uuid>`, 2);
5402
+ }
5403
+ const config = requireLogin(args);
5404
+ const role = resolveRoleRef((await fetchPermissions(config)).roles, roleRef);
5405
+ const member = resolveMemberRef(await fetchMembers(config), userRef);
5406
+ const base = `/api/organizations/${config.orgUuid}/roles/${role.uuid}/members`;
5407
+ if (add) {
5408
+ await authedNoContent(config, base, `Add ${member.email} to "${role.name}"`, jsonBody({ user_uuid: member.uuid }));
5409
+ console.log(`Added ${member.email} to role "${role.name}".`);
5410
+ }
5411
+ else {
5412
+ await authedNoContent(config, `${base}/${member.uuid}`, `Remove ${member.email} from "${role.name}"`, {
5413
+ method: "DELETE",
5414
+ });
5415
+ console.log(`Removed ${member.email} from role "${role.name}".`);
5416
+ }
5417
+ }
5418
+ async function commandRolesGrants(args) {
5419
+ rejectUnknownOptions(args, ["json", "apiUrl"], "roles", ROLES_HELP);
5420
+ const config = requireLogin(args);
5421
+ const { roles, grants } = await fetchPermissions(config);
5422
+ if (args.options.json) {
5423
+ console.log(JSON.stringify(grants, null, 2));
5424
+ return;
5425
+ }
5426
+ if (grants.length === 0) {
5427
+ console.log("No grants configured.");
5428
+ return;
5429
+ }
5430
+ const roleNames = new Map(roles.map((r) => [r.uuid, r.name]));
5431
+ const members = await fetchMembers(config);
5432
+ const userEmails = new Map(members.map((m) => [m.uuid, m.email]));
5433
+ const apps = await fetchApps(config);
5434
+ const appSlugs = new Map(apps.map((a) => [a.uuid, a.app_slug]));
5435
+ console.log(grantTable(grants, roleNames, userEmails, appSlugs));
5436
+ }
5437
+ async function commandRolesGrant(args) {
5438
+ rejectUnknownOptions(args, ["subject", "resource", "ids", "apiUrl"], "roles", ROLES_HELP);
5439
+ const spec = parseGrantSubject(optionString(args, "subject"));
5440
+ const resourceType = parseResourceType(optionString(args, "resource"));
5441
+ const resourceRefs = parseResourceIds(optionString(args, "ids"));
5442
+ const config = requireLogin(args);
5443
+ const subject = await resolveGrantSubject(config, spec);
5444
+ let resourceIds = resourceRefs;
5445
+ if (resourceType === "app") {
5446
+ if (resourceRefs.includes("*")) {
5447
+ throw new CliError("Apps have no wildcard — grant them one by one.", 2);
5448
+ }
5449
+ const apps = await fetchApps(config);
5450
+ resourceIds = resourceRefs.map((ref) => resolveAppRef(apps, ref).uuid);
5451
+ }
5452
+ const created = (await authedJson(config, `/api/organizations/${config.orgUuid}/permissions/grants`, "Create grant", jsonBody({ ...subject, resource_type: resourceType, resource_ids: resourceIds })));
5453
+ if (created.length === 0) {
5454
+ console.log("No new grants created (they already existed).");
5455
+ return;
5456
+ }
5457
+ console.log(`Granted ${resourceType} [${resourceRefs.join(", ")}] (${created.length} row(s) added).`);
5458
+ }
5459
+ async function commandRolesRevoke(args) {
5460
+ rejectUnknownOptions(args, ["apiUrl"], "roles", ROLES_HELP);
5461
+ const id = args.rest[1];
5462
+ if (!id || !/^\d+$/.test(id)) {
5463
+ throw new CliError("Usage: railcode roles revoke <grant_id> (see `railcode roles grants`).", 2);
5464
+ }
5465
+ const config = requireLogin(args);
5466
+ await authedNoContent(config, `/api/organizations/${config.orgUuid}/permissions/grants/${id}`, `Revoke grant ${id}`, { method: "DELETE" });
5467
+ console.log(`Grant ${id} revoked.`);
5468
+ }
5469
+ async function commandRolesMaterialize(args) {
5470
+ rejectUnknownOptions(args, ["subject", "resource", "apiUrl"], "roles", ROLES_HELP);
5471
+ const spec = parseGrantSubject(optionString(args, "subject"));
5472
+ const resourceType = parseResourceType(optionString(args, "resource"));
5473
+ const config = requireLogin(args);
5474
+ const subject = await resolveGrantSubject(config, spec);
5475
+ const created = (await authedJson(config, `/api/organizations/${config.orgUuid}/permissions/grants/materialize`, "Materialize wildcard", jsonBody({ ...subject, resource_type: resourceType })));
5476
+ console.log(`Materialized ${resourceType} wildcard into ${created.length} explicit grant(s).`);
5477
+ }
5478
+ async function commandRolesEffective(args) {
5479
+ rejectUnknownOptions(args, ["json", "apiUrl"], "roles", ROLES_HELP);
5480
+ const ref = args.rest[1];
5481
+ if (!ref)
5482
+ throw new CliError("Usage: railcode roles effective <email|uuid>", 2);
5483
+ const config = requireLogin(args);
5484
+ const member = resolveMemberRef(await fetchMembers(config), ref);
5485
+ const eff = (await authedJson(config, `/api/organizations/${config.orgUuid}/permissions/effective/${member.uuid}`, "Effective access"));
5486
+ if (args.options.json) {
5487
+ console.log(JSON.stringify(eff, null, 2));
5488
+ return;
5489
+ }
5490
+ console.log(renderEffective(eff, member.email));
5491
+ }
5492
+ async function commandRolesCatalog(args) {
5493
+ rejectUnknownOptions(args, ["json", "apiUrl"], "roles", ROLES_HELP);
5494
+ const config = requireLogin(args);
5495
+ const catalog = (await authedJson(config, `/api/organizations/${config.orgUuid}/permissions/catalog`, "Grant catalog"));
5496
+ if (args.options.json) {
5497
+ console.log(JSON.stringify(catalog, null, 2));
5498
+ return;
5499
+ }
5500
+ const line = (label, items) => console.log(`${label}: ${items.length ? items.join(", ") : "-"}`);
5501
+ line("apps", catalog.apps.map((a) => a.app_slug));
5502
+ line("connections", catalog.connections.map((c) => c.name));
5503
+ line("service_connectors", catalog.service_connectors.map((c) => c.name));
5504
+ line("saved_queries", catalog.saved_queries.map((q) => q.name));
5505
+ line("agents", catalog.agents.map((a) => a.name));
5506
+ }
5507
+ // ---------------------------------------------------------------------------
5508
+ // apps — app management + access policy (`railcode apps`)
5509
+ // ---------------------------------------------------------------------------
5510
+ const APPS_HELP = `railcode apps — apps + access policy
5511
+
5512
+ Usage:
5513
+ railcode apps list List apps you can see
5514
+ railcode apps show <app> Show one app's details
5515
+ railcode apps access <app> Show an app's access policy + grants
5516
+ railcode apps set-access <app> --mode organization|private|restricted
5517
+ [--members <email,email>] Set the policy (members: restricted only)
5518
+ railcode apps transfer <app> --to <email|uuid> Transfer ownership
5519
+ railcode apps delete <app> [--yes] Delete an app (deploys + data, irreversible)
5520
+
5521
+ <app> is an app slug or UUID. set-access/transfer/delete need manage rights
5522
+ (owner or org admin). delete prompts for confirmation on a TTY; pass --yes to skip
5523
+ (required in non-interactive sessions).
5524
+
5525
+ Options:
5526
+ --json Print raw JSON
5527
+ --yes Skip the delete confirmation prompt
5528
+ `;
5529
+ async function commandApps(args) {
5530
+ try {
5531
+ const sub = args.rest[0] ?? "";
5532
+ switch (sub) {
5533
+ case "list":
5534
+ case "ls":
5535
+ await commandAppsList(args);
5536
+ return;
5537
+ case "show":
5538
+ case "get":
5539
+ await commandAppsShow(args);
5540
+ return;
5541
+ case "access":
5542
+ await commandAppsAccess(args);
5543
+ return;
5544
+ case "set-access":
5545
+ await commandAppsSetAccess(args);
5546
+ return;
5547
+ case "transfer":
5548
+ await commandAppsTransfer(args);
5549
+ return;
5550
+ case "delete":
5551
+ case "rm":
5552
+ await commandAppsDelete(args);
5553
+ return;
5554
+ case "":
5555
+ case "help":
5556
+ console.log(APPS_HELP);
5557
+ return;
5558
+ default:
5559
+ throw new CliError(`Unknown apps command: ${sub}\n\n${APPS_HELP}`, 2);
5560
+ }
5561
+ }
5562
+ catch (error) {
5563
+ if (error instanceof OrgAdminError)
5564
+ throw new CliError(error.message, 2);
5565
+ throw error;
5566
+ }
5567
+ }
5568
+ async function commandAppsList(args) {
5569
+ rejectUnknownOptions(args, ["json", "apiUrl"], "apps", APPS_HELP);
5570
+ const config = requireLogin(args);
5571
+ const apps = await fetchApps(config);
5572
+ if (args.options.json) {
5573
+ console.log(JSON.stringify(apps, null, 2));
5574
+ return;
5575
+ }
5576
+ if (apps.length === 0) {
5577
+ console.log("No apps yet. Scaffold one: railcode init <app>.");
5578
+ return;
5579
+ }
5580
+ console.log(appTable(apps));
5581
+ }
5582
+ async function commandAppsShow(args) {
5583
+ rejectUnknownOptions(args, ["json", "apiUrl"], "apps", APPS_HELP);
5584
+ const ref = args.rest[1];
5585
+ if (!ref)
5586
+ throw new CliError("Usage: railcode apps show <app>", 2);
5587
+ const config = requireLogin(args);
5588
+ const app = resolveAppRef(await fetchApps(config), ref);
5589
+ const full = (await authedJson(config, `/api/organizations/${config.orgUuid}/apps/${app.uuid}`, "Get app"));
5590
+ if (args.options.json) {
5591
+ console.log(JSON.stringify(full, null, 2));
5592
+ return;
5593
+ }
5594
+ const row = (label, value) => ` ${`${label}:`.padEnd(14)}${value}`;
5595
+ console.log(`${full.app_slug} (${full.name})`);
5596
+ console.log(row("uuid", String(full.uuid)));
5597
+ console.log(row("status", String(full.status)));
5598
+ console.log(row("access", String(full.access_mode)));
5599
+ console.log(row("your role", full.your_role ?? "-"));
5600
+ console.log(row("can manage", full.can_manage ? "yes" : "no"));
5601
+ console.log(row("has manifest", full.has_manifest ? "yes" : "no"));
5602
+ if (full.last_deployed_at)
5603
+ console.log(row("deployed", String(full.last_deployed_at)));
5604
+ }
5605
+ async function commandAppsAccess(args) {
5606
+ rejectUnknownOptions(args, ["json", "apiUrl"], "apps", APPS_HELP);
5607
+ const ref = args.rest[1];
5608
+ if (!ref)
5609
+ throw new CliError("Usage: railcode apps access <app>", 2);
5610
+ const config = requireLogin(args);
5611
+ const app = resolveAppRef(await fetchApps(config), ref);
5612
+ const access = (await authedJson(config, `/api/organizations/${config.orgUuid}/apps/${app.uuid}/access`, "Get app access"));
5613
+ // App access has two sources: the app's own policy (mode + per-user grants,
5614
+ // above) AND grant-substrate rows (`roles grant --resource app`, plus the
5615
+ // access granted to apps/agents). The policy view alone is incomplete, so also
5616
+ // surface any org_grants that open THIS app.
5617
+ const { roles, grants } = await fetchPermissions(config);
5618
+ const substrate = grants.filter((g) => g.resource_type === "app" && g.resource_id === app.uuid);
5619
+ if (args.options.json) {
5620
+ console.log(JSON.stringify({ ...access, grants: access.grants, org_grants: substrate }, null, 2));
5621
+ return;
5622
+ }
5623
+ console.log(renderAppAccess(app.app_slug, access));
5624
+ if (substrate.length > 0) {
5625
+ const roleNames = new Map(roles.map((r) => [r.uuid, r.name]));
5626
+ const members = await fetchMembers(config);
5627
+ const userEmails = new Map(members.map((m) => [m.uuid, m.email]));
5628
+ console.log(" also granted access via roles/grants:");
5629
+ for (const g of substrate) {
5630
+ console.log(` ${grantSubjectLabel(g, roleNames, userEmails)}`);
5631
+ }
5632
+ }
5633
+ }
5634
+ async function commandAppsSetAccess(args) {
5635
+ rejectUnknownOptions(args, ["mode", "members", "json", "apiUrl"], "apps", APPS_HELP);
5636
+ const ref = args.rest[1];
5637
+ if (!ref)
5638
+ throw new CliError("Usage: railcode apps set-access <app> --mode <mode> [--members ...]", 2);
5639
+ const mode = parseAccessMode(optionString(args, "mode"));
5640
+ const memberRefs = parseMemberList(optionString(args, "members"));
5641
+ if (mode !== "restricted" && memberRefs.length > 0) {
5642
+ throw new CliError("--members only applies to --mode restricted.", 2);
5643
+ }
5644
+ const config = requireLogin(args);
5645
+ const app = resolveAppRef(await fetchApps(config), ref);
5646
+ let members = [];
5647
+ if (mode === "restricted" && memberRefs.length > 0) {
5648
+ const all = await fetchMembers(config);
5649
+ members = memberRefs.map((r) => resolveMemberRef(all, r).uuid);
5650
+ }
5651
+ const access = (await authedJson(config, `/api/organizations/${config.orgUuid}/apps/${app.uuid}/access`, "Set app access", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode, members }) }));
5652
+ if (args.options.json) {
5653
+ console.log(JSON.stringify(access, null, 2));
5654
+ return;
5655
+ }
5656
+ console.log(renderAppAccess(app.app_slug, access));
5657
+ }
5658
+ async function commandAppsTransfer(args) {
5659
+ rejectUnknownOptions(args, ["to", "apiUrl"], "apps", APPS_HELP);
5660
+ const ref = args.rest[1];
5661
+ const to = optionString(args, "to");
5662
+ if (!ref || !to)
5663
+ throw new CliError("Usage: railcode apps transfer <app> --to <email|uuid>", 2);
5664
+ const config = requireLogin(args);
5665
+ const app = resolveAppRef(await fetchApps(config), ref);
5666
+ const newOwner = resolveMemberRef(await fetchMembers(config), to);
5667
+ await authedJson(config, `/api/organizations/${config.orgUuid}/apps/${app.uuid}/owner`, "Transfer app owner", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_uuid: newOwner.uuid }) });
5668
+ console.log(`${app.app_slug} is now owned by ${newOwner.email}.`);
5669
+ }
5670
+ async function commandAppsDelete(args) {
5671
+ rejectUnknownOptions(args, ["yes", "apiUrl"], "apps", APPS_HELP);
5672
+ const ref = args.rest[1];
5673
+ if (!ref)
5674
+ throw new CliError("Usage: railcode apps delete <app>", 2);
5675
+ const config = requireLogin(args);
5676
+ const app = resolveAppRef(await fetchApps(config), ref);
5677
+ if (args.options.yes !== true) {
5678
+ if (!process.stdin.isTTY) {
5679
+ throw new CliError("Pass --yes to delete an app in a non-interactive session.", 2);
5680
+ }
5681
+ const answer = await prompt(`Delete app "${app.app_slug}"? This removes its deploys + data and cannot be undone. [y/N] `);
5682
+ if (!["y", "yes"].includes(answer.trim().toLowerCase())) {
5683
+ throw new CliError("Delete cancelled.", 1);
5684
+ }
5685
+ }
5686
+ await authedNoContent(config, `/api/organizations/${config.orgUuid}/apps/${app.uuid}`, `Delete app "${app.app_slug}"`, { method: "DELETE" });
5687
+ console.log(`App "${app.app_slug}" deleted.`);
5688
+ }
5689
+ // ---------------------------------------------------------------------------
5690
+ // analytics — per-app pageviews (`railcode analytics <app>`)
5691
+ // ---------------------------------------------------------------------------
5692
+ const ANALYTICS_HELP = `railcode analytics — per-app pageview analytics (admin/owner)
5693
+
5694
+ Usage:
5695
+ railcode analytics <app> [--range 7d|30d|90d] Views, uniques, daily, top paths/users
5696
+
5697
+ <app> is an app slug or UUID. Default range 30d.
5698
+
5699
+ Options:
5700
+ --json Print the raw analytics object
5701
+ `;
5702
+ async function commandAnalytics(args) {
5703
+ try {
5704
+ const ref = args.rest[0];
5705
+ if (!ref || ref === "help") {
5706
+ console.log(ANALYTICS_HELP);
5707
+ return;
5708
+ }
5709
+ rejectUnknownOptions(args, ["range", "json", "apiUrl"], "analytics", ANALYTICS_HELP);
5710
+ const range = parseRange(optionString(args, "range"));
5711
+ const config = requireLogin(args);
5712
+ const app = resolveAppRef(await fetchApps(config), ref);
5713
+ const qs = range ? `?range=${range}` : "";
5714
+ const data = (await authedJson(config, `/api/organizations/${config.orgUuid}/apps/${app.uuid}/analytics${qs}`, "Get analytics"));
5715
+ if (args.options.json) {
5716
+ console.log(JSON.stringify(data, null, 2));
5717
+ return;
5718
+ }
5719
+ console.log(renderAnalytics(app.app_slug, data));
5720
+ }
5721
+ catch (error) {
5722
+ if (error instanceof OrgAdminError || error instanceof ObservabilityError) {
5723
+ throw new CliError(error.message, 2);
5724
+ }
5725
+ throw error;
5726
+ }
5727
+ }
5728
+ // ---------------------------------------------------------------------------
5729
+ // logs — org observability streams (`railcode logs <stream>`)
5730
+ // ---------------------------------------------------------------------------
5731
+ const LOGS_HELP = `railcode logs — org observability (admin)
5732
+
5733
+ Usage:
5734
+ railcode logs <stream> [filters] Recent log rows (newest first)
5735
+ railcode logs <stream> <request_id> Full detail for one entry
5736
+
5737
+ Streams:
5738
+ connector data-connection SQL queries (connection:manage)
5739
+ service-connector service-connector proxy calls (service-connector:manage)
5740
+ llm LLM gateway calls (llm:manage)
5741
+ email email gateway sends (email:manage)
5742
+ agent managed-agent runs (agent:manage)
5743
+
5744
+ Filters (each optional; unsupported ones are rejected per stream):
5745
+ --app <slug|uuid> Only rows for this app
5746
+ --user <email|uuid> Only rows for this user
5747
+ --connector <name> Only this connector (connector/service-connector)
5748
+ --agent <uuid> Only this agent (agent stream)
5749
+ --source app|agent LLM source (llm stream)
5750
+ --status <value> Stream-specific status (e.g. success|error)
5751
+ --limit <1..500> Max rows (default 100)
5752
+ --json Print raw JSON
5753
+ `;
5754
+ async function commandLogs(args) {
5755
+ try {
5756
+ const streamName = args.rest[0] ?? "";
5757
+ if (streamName === "" || streamName === "help") {
5758
+ console.log(LOGS_HELP);
5759
+ return;
5760
+ }
5761
+ rejectUnknownOptions(args, ["app", "user", "connector", "agent", "source", "status", "limit", "json", "apiUrl"], "logs", LOGS_HELP);
5762
+ const stream = resolveLogStream(streamName);
5763
+ const requestId = args.rest[1];
5764
+ if (args.rest.length > 2) {
5765
+ throw new CliError(`railcode logs ${stream.key} takes at most one request id.`, 2);
5766
+ }
5767
+ const config = requireLogin(args);
5768
+ if (requestId) {
5769
+ await logsShowDetail(config, stream, requestId, args);
5770
+ return;
5771
+ }
5772
+ await logsList(config, stream, args);
5773
+ }
5774
+ catch (error) {
5775
+ if (error instanceof ObservabilityError || error instanceof OrgAdminError) {
5776
+ throw new CliError(error.message, 2);
5777
+ }
5778
+ throw error;
5779
+ }
5780
+ }
5781
+ // Turn --app/--user/--agent references into UUIDs the API expects. Slugs/emails are
5782
+ // resolved against the org's apps/members; a bare UUID passes through unchanged.
5783
+ async function resolveLogFilters(config, stream, args) {
5784
+ const out = {};
5785
+ const appRef = optionString(args, "app");
5786
+ const userRef = optionString(args, "user");
5787
+ const agentRef = optionString(args, "agent");
5788
+ const connector = optionString(args, "connector");
5789
+ const source = optionString(args, "source");
5790
+ const status = optionString(args, "status");
5791
+ const ensure = (key) => {
5792
+ if (!stream.filters.includes(key)) {
5793
+ throw new ObservabilityError(`--${key} is not a valid filter for ${stream.key} logs.`);
5794
+ }
5795
+ };
5796
+ if (appRef !== undefined) {
5797
+ ensure("app");
5798
+ out.app = /^[0-9a-f-]{36}$/i.test(appRef) ? appRef : resolveAppRef(await fetchApps(config), appRef).uuid;
5799
+ }
5800
+ if (userRef !== undefined) {
5801
+ ensure("user");
5802
+ out.user = /^[0-9a-f-]{36}$/i.test(userRef) ? userRef : resolveMemberRef(await fetchMembers(config), userRef).uuid;
5803
+ }
5804
+ if (agentRef !== undefined) {
5805
+ ensure("agent");
5806
+ out.agent = agentRef;
5807
+ }
5808
+ if (connector !== undefined) {
5809
+ ensure("connector");
5810
+ out.connector = connector;
5811
+ }
5812
+ if (source !== undefined) {
5813
+ ensure("source");
5814
+ out.source = validateLogSource(stream, source);
5815
+ }
5816
+ if (status !== undefined) {
5817
+ ensure("status");
5818
+ out.status = validateLogStatus(stream, status);
5819
+ }
5820
+ out.limit = parseLimit(optionString(args, "limit"));
5821
+ return out;
5822
+ }
5823
+ async function logsList(config, stream, args) {
5824
+ const filters = await resolveLogFilters(config, stream, args);
5825
+ const rows = (await authedJson(config, `/api/organizations/${config.orgUuid}${stream.path}${buildLogQuery(filters)}`, `List ${stream.key} logs`));
5826
+ if (args.options.json) {
5827
+ console.log(JSON.stringify(rows, null, 2));
5828
+ return;
5829
+ }
5830
+ if (rows.length === 0) {
5831
+ console.log(`No ${stream.label} logged (matching your filters).`);
5832
+ return;
5833
+ }
5834
+ console.log(logTable(stream, rows));
5835
+ }
5836
+ async function logsShowDetail(config, stream, requestId, args) {
5837
+ const detail = await authedJson(config, `/api/organizations/${config.orgUuid}${stream.path}/${encodeURIComponent(requestId)}`, `Get ${stream.key} log`);
5838
+ // Detail payloads vary a lot per stream; JSON is the faithful, complete view.
5839
+ console.log(JSON.stringify(detail, null, 2));
5840
+ void args;
5841
+ }
5842
+ // ---------------------------------------------------------------------------
5843
+ // connections — org data connectors (`railcode connections`)
5844
+ // ---------------------------------------------------------------------------
5845
+ const CONNECTIONS_HELP = `railcode connections — org data connectors (admin)
5846
+
5847
+ Usage:
5848
+ railcode connections list List data connectors
5849
+ railcode connections create --name <n> --kind postgres|bigquery|turso
5850
+ --config <json> --credentials <json>
5851
+ [--config-file <f>] [--credentials-file <f>]
5852
+ Create/replace a connector (dials before saving)
5853
+ railcode connections delete <name|uuid> Delete a connector
5854
+
5855
+ Config/credentials fields per engine:
5856
+ postgres config {host,database,username,port?,sslmode?} credentials {password}
5857
+ bigquery config {project_id,dataset} credentials {service_account_json}
5858
+ turso config {url} credentials {auth_token}
5859
+
5860
+ Options:
5861
+ --json Print raw JSON (list)
5862
+ `;
5863
+ async function commandConnections(args) {
5864
+ try {
5865
+ const sub = args.rest[0] ?? "";
5866
+ switch (sub) {
5867
+ case "list":
5868
+ case "ls":
5869
+ await commandConnectionsList(args);
5870
+ return;
5871
+ case "create":
5872
+ await commandConnectionsCreate(args);
5873
+ return;
5874
+ case "delete":
5875
+ case "rm":
5876
+ await commandConnectionsDelete(args);
5877
+ return;
5878
+ case "":
5879
+ case "help":
5880
+ console.log(CONNECTIONS_HELP);
5881
+ return;
5882
+ default:
5883
+ throw new CliError(`Unknown connections command: ${sub}\n\n${CONNECTIONS_HELP}`, 2);
5884
+ }
5885
+ }
5886
+ catch (error) {
5887
+ if (error instanceof ConnectionAdminError)
5888
+ throw new CliError(error.message, 2);
5889
+ throw error;
5890
+ }
5891
+ }
5892
+ async function fetchConnections(config) {
5893
+ return (await authedJson(config, `/api/organizations/${config.orgUuid}/connections`, "List connections"));
5894
+ }
5895
+ async function commandConnectionsList(args) {
5896
+ rejectUnknownOptions(args, ["json", "apiUrl"], "connections", CONNECTIONS_HELP);
5897
+ const config = requireLogin(args);
5898
+ const conns = await fetchConnections(config);
5899
+ if (args.options.json) {
5900
+ console.log(JSON.stringify(conns, null, 2));
5901
+ return;
5902
+ }
5903
+ if (conns.length === 0) {
5904
+ console.log("No data connectors configured for this organization.");
5905
+ return;
5906
+ }
5907
+ console.log(connectionTable(conns));
5908
+ }
5909
+ async function commandConnectionsCreate(args) {
5910
+ rejectUnknownOptions(args, ["name", "kind", "config", "configFile", "credentials", "credentialsFile", "apiUrl"], "connections", CONNECTIONS_HELP);
5911
+ const kind = parseConnectionEngine(optionString(args, "kind"));
5912
+ const configObj = readJsonObjectOption(args, "config", "configFile", "--config");
5913
+ const credentials = readJsonObjectOption(args, "credentials", "credentialsFile", "--credentials");
5914
+ const body = buildConnectionCreateBody({
5915
+ name: optionString(args, "name"),
5916
+ kind,
5917
+ config: configObj,
5918
+ credentials,
5919
+ });
5920
+ const config = requireLogin(args);
5921
+ const created = (await authedJson(config, `/api/organizations/${config.orgUuid}/connections`, "Create connection", jsonBody(body)));
5922
+ console.log(`Connection "${created.name}" (${created.kind}) → ${created.target}.`);
5923
+ }
5924
+ async function commandConnectionsDelete(args) {
5925
+ rejectUnknownOptions(args, ["apiUrl"], "connections", CONNECTIONS_HELP);
5926
+ const ref = args.rest[1];
5927
+ if (!ref)
5928
+ throw new CliError("Usage: railcode connections delete <name|uuid>", 2);
5929
+ const config = requireLogin(args);
5930
+ const conn = resolveConnectionRef(await fetchConnections(config), ref);
5931
+ await authedNoContent(config, `/api/organizations/${config.orgUuid}/connections/${conn.uuid}`, `Delete connection "${conn.name}"`, { method: "DELETE" });
5932
+ console.log(`Connection "${conn.name}" deleted (its saved queries + grants were removed too).`);
5933
+ }
5934
+ // Read a JSON-object option from an inline `--foo` value or a `--foo-file` path.
5935
+ function readJsonObjectOption(args, inlineKey, fileKey, label) {
5936
+ const inline = optionString(args, inlineKey);
5937
+ const file = optionString(args, fileKey);
5938
+ if (inline !== undefined && file !== undefined) {
5939
+ throw new CliError(`Pass either ${label} or ${label}-file, not both.`, 2);
5940
+ }
5941
+ if (file !== undefined) {
5942
+ const resolved = resolve(process.cwd(), file);
5943
+ if (!existsSync(resolved))
5944
+ throw new CliError(`${label}-file not found: ${file}`, 2);
5945
+ return parseJsonObject(readFileSync(resolved, "utf8"), `${label}-file`);
5946
+ }
5947
+ return parseJsonObject(inline, label);
5948
+ }
5949
+ // ---------------------------------------------------------------------------
4943
5950
  // Config persistence
4944
5951
  // ---------------------------------------------------------------------------
4945
5952
  function loadConfig() {
@@ -5121,9 +6128,10 @@ class CliError extends Error {
5121
6128
  // dev — local development server (`railcode dev`)
5122
6129
  //
5123
6130
  // 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.
6131
+ // identity is synthetic, KV + files live on local disk (isolated per storage scope —
6132
+ // shared/user/role so `db.user`/`files.role(uuid)` behave like prod). llm/sql/queries/
6133
+ // connectors/email proxy to the real instance as the signed-in user (their token + org
6134
+ // grants) via the org/user-scoped routes — no deploy needed. Mirrors railcode-core's dev.
5127
6135
  // ---------------------------------------------------------------------------
5128
6136
  const DEFAULT_DEV_PORT = 7331;
5129
6137
  const DEFAULT_ASSET_PORT = 5173;
@@ -5160,8 +6168,9 @@ async function commandDev(args) {
5160
6168
  app: manifest.app,
5161
6169
  root: plan.root,
5162
6170
  sdkPath,
5163
- kv: new DevKvStore(join(stateDir, "kv.json")),
5164
- files: new DevFileStore(stateDir),
6171
+ stateDir,
6172
+ kvStores: new Map(),
6173
+ fileStores: new Map(),
5165
6174
  port: requestedPort,
5166
6175
  config,
5167
6176
  };
@@ -5564,15 +6573,21 @@ class DevKvStore {
5564
6573
  }
5565
6574
  // ── local file store (bytes on disk, metadata in files.json) ──────────────────
5566
6575
  class DevFileStore {
5567
- dir;
5568
- constructor(dir) {
5569
- this.dir = dir;
6576
+ blobRoot;
6577
+ metaFile;
6578
+ // Scope-aware, mirroring the backend's physical layout: shared keeps the legacy
6579
+ // `files/` blob dir + `files.json` meta (byte-compatible with pre-scopes dev state);
6580
+ // user/role nest under a per-scope blob dir + meta so namespaces never collide.
6581
+ constructor(dir, scopeSlug) {
6582
+ this.blobRoot = scopeSlug ? join(dir, `files-${scopeSlug}`) : join(dir, "files");
6583
+ this.metaFile = scopeSlug ? join(dir, `files.${scopeSlug}.json`) : join(dir, "files.json");
6584
+ mkdirSync(this.blobRoot, { recursive: true });
5570
6585
  }
5571
6586
  metaPath() {
5572
- return join(this.dir, "files.json");
6587
+ return this.metaFile;
5573
6588
  }
5574
6589
  blobPath(name) {
5575
- return join(this.dir, "files", encodeURIComponent(name));
6590
+ return join(this.blobRoot, encodeURIComponent(name));
5576
6591
  }
5577
6592
  readMeta() {
5578
6593
  const p = this.metaPath();
@@ -5675,7 +6690,79 @@ async function handleLocalApi(ctx, req, res, url) {
5675
6690
  }
5676
6691
  sendJson(res, 404, { detail: "not found" });
5677
6692
  }
6693
+ // role/user selectors are UUIDs on the backend (it resolves them to FK ids). Enforce
6694
+ // the same here so a selector can NEVER reach the filesystem as a path segment — the
6695
+ // slug feeds kv.<slug>.json / files-<slug>/, so an unvalidated "../.." would traverse
6696
+ // out of the dev state dir. UUIDs contain only hex + hyphens, so this closes it.
6697
+ 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}$/;
6698
+ function resolveDevScope(url) {
6699
+ const scope = (url.searchParams.get("scope") || "shared").toLowerCase();
6700
+ const role = url.searchParams.get("role");
6701
+ const user = url.searchParams.get("user");
6702
+ if (scope === "shared") {
6703
+ if (role || user) {
6704
+ return { ok: false, status: 400, detail: "shared scope takes no role or user selector" };
6705
+ }
6706
+ return { ok: true, scope: { key: "shared", slug: "shared" } };
6707
+ }
6708
+ if (scope === "user") {
6709
+ if (role)
6710
+ return { ok: false, status: 400, detail: "user scope takes no role selector" };
6711
+ if (user !== null && !DEV_UUID_RE.test(user)) {
6712
+ return { ok: false, status: 400, detail: "malformed user id" };
6713
+ }
6714
+ const uuid = user || DEV_USER_UUID;
6715
+ return { ok: true, scope: { key: `user:${uuid}`, slug: `users-${uuid}` } };
6716
+ }
6717
+ if (scope === "role") {
6718
+ if (user)
6719
+ return { ok: false, status: 400, detail: "role scope takes no user selector" };
6720
+ if (!role)
6721
+ return { ok: false, status: 400, detail: "role scope requires a role id" };
6722
+ if (!DEV_UUID_RE.test(role))
6723
+ return { ok: false, status: 400, detail: "malformed role id" };
6724
+ return { ok: true, scope: { key: `role:${role}`, slug: `roles-${role}` } };
6725
+ }
6726
+ return { ok: false, status: 400, detail: `invalid scope: ${scope}` };
6727
+ }
6728
+ function kvForScope(ctx, scope) {
6729
+ let store = ctx.kvStores.get(scope.key);
6730
+ if (!store) {
6731
+ const path = scope.key === "shared"
6732
+ ? join(ctx.stateDir, "kv.json")
6733
+ : join(ctx.stateDir, `kv.${scope.slug}.json`);
6734
+ store = new DevKvStore(path);
6735
+ ctx.kvStores.set(scope.key, store);
6736
+ }
6737
+ return store;
6738
+ }
6739
+ function filesForScope(ctx, scope) {
6740
+ let store = ctx.fileStores.get(scope.key);
6741
+ if (!store) {
6742
+ store = new DevFileStore(ctx.stateDir, scope.key === "shared" ? undefined : scope.slug);
6743
+ ctx.fileStores.set(scope.key, store);
6744
+ }
6745
+ return store;
6746
+ }
6747
+ // Re-attach the request's scope selectors to a same-origin /_api URL (the local-proxy
6748
+ // blob upload URL, download URLs) so the follow-up call resolves the identical scope —
6749
+ // mirrors the backend's _scoped_url.
6750
+ function scopeQuerySuffix(url) {
6751
+ const parts = [];
6752
+ for (const k of ["scope", "role", "user"]) {
6753
+ const v = url.searchParams.get(k);
6754
+ if (v)
6755
+ parts.push(`${k}=${encodeURIComponent(v)}`);
6756
+ }
6757
+ return parts.length ? `&${parts.join("&")}` : "";
6758
+ }
5678
6759
  async function handleKv(ctx, req, res, path, url) {
6760
+ const resolved = resolveDevScope(url);
6761
+ if (!resolved.ok) {
6762
+ sendJson(res, resolved.status, { detail: resolved.detail });
6763
+ return;
6764
+ }
6765
+ const kv = kvForScope(ctx, resolved.scope);
5679
6766
  // path: /kv/{collection} | /kv/{collection}/{key...}
5680
6767
  const rest = path.slice("/kv/".length);
5681
6768
  const slash = rest.indexOf("/");
@@ -5695,7 +6782,7 @@ async function handleKv(ctx, req, res, path, url) {
5695
6782
  return;
5696
6783
  }
5697
6784
  try {
5698
- const records = ctx.kv.records(collection);
6785
+ const records = kv.records(collection);
5699
6786
  const params = parseKvParams(url);
5700
6787
  if (url.searchParams.get("count") === "true") {
5701
6788
  sendJson(res, 200, { count: kvCount(records, params) });
@@ -5713,7 +6800,7 @@ async function handleKv(ctx, req, res, path, url) {
5713
6800
  return;
5714
6801
  }
5715
6802
  if (req.method === "GET") {
5716
- const rec = ctx.kv.get(collection, key);
6803
+ const rec = kv.get(collection, key);
5717
6804
  if (!rec)
5718
6805
  sendJson(res, 404, { detail: "Key not found" });
5719
6806
  else
@@ -5734,11 +6821,11 @@ async function handleKv(ctx, req, res, path, url) {
5734
6821
  sendJson(res, 413, { detail: "Value too large" });
5735
6822
  return;
5736
6823
  }
5737
- sendJson(res, 200, ctx.kv.put(collection, key, payload.value));
6824
+ sendJson(res, 200, kv.put(collection, key, payload.value));
5738
6825
  return;
5739
6826
  }
5740
6827
  if (req.method === "DELETE") {
5741
- ctx.kv.delete(collection, key);
6828
+ kv.delete(collection, key);
5742
6829
  sendStatus(res, 204);
5743
6830
  return;
5744
6831
  }
@@ -5776,8 +6863,44 @@ function parseKvParams(url) {
5776
6863
  };
5777
6864
  }
5778
6865
  async function handleFiles(ctx, req, res, path, url) {
6866
+ const resolved = resolveDevScope(url);
6867
+ if (!resolved.ok) {
6868
+ sendJson(res, resolved.status, { detail: resolved.detail });
6869
+ return;
6870
+ }
6871
+ const files = filesForScope(ctx, resolved.scope);
6872
+ const scopeSuffix = scopeQuerySuffix(url);
5779
6873
  if (path === "/files" && req.method === "GET") {
5780
- sendJson(res, 200, { items: ctx.files.list() });
6874
+ sendJson(res, 200, { items: files.list() });
6875
+ return;
6876
+ }
6877
+ if (path === "/files/urls" && req.method === "POST") {
6878
+ const body = await readBody(req);
6879
+ let payload;
6880
+ try {
6881
+ payload = JSON.parse(body.toString("utf8") || "{}");
6882
+ }
6883
+ catch {
6884
+ sendJson(res, 400, { detail: "invalid JSON body" });
6885
+ return;
6886
+ }
6887
+ const names = Array.isArray(payload.names)
6888
+ ? payload.names.filter((n) => typeof n === "string")
6889
+ : [];
6890
+ // Local proxy: download URLs carry the scope so a later GET re-resolves it.
6891
+ const scopeQuery = scopeSuffix ? `?${scopeSuffix.slice(1)}` : "";
6892
+ const items = [];
6893
+ const missing = [];
6894
+ for (const name of [...new Set(names)]) {
6895
+ const safe = devSafeName(name);
6896
+ if (safe && files.get(safe)) {
6897
+ items.push({ name, url: `/_api/files/${encodeURIComponent(safe)}${scopeQuery}`, expires_in: null });
6898
+ }
6899
+ else {
6900
+ missing.push(name);
6901
+ }
6902
+ }
6903
+ sendJson(res, 200, { items, missing });
5781
6904
  return;
5782
6905
  }
5783
6906
  if (path === "/files" && req.method === "POST") {
@@ -5795,10 +6918,12 @@ async function handleFiles(ctx, req, res, path, url) {
5795
6918
  sendJson(res, 400, { detail: "Invalid file name" });
5796
6919
  return;
5797
6920
  }
6921
+ // The blob URL carries the scope selectors so the follow-up PUT lands in the
6922
+ // same scope (mirrors the backend's _scoped_url on the proxy path).
5798
6923
  sendJson(res, 200, {
5799
6924
  mode: "proxy",
5800
6925
  method: "PUT",
5801
- url: `/_api/files/blob?name=${encodeURIComponent(safe)}`,
6926
+ url: `/_api/files/blob?name=${encodeURIComponent(safe)}${scopeSuffix}`,
5802
6927
  object_key: safe,
5803
6928
  headers: {},
5804
6929
  expires_in: null,
@@ -5817,7 +6942,7 @@ async function handleFiles(ctx, req, res, path, url) {
5817
6942
  return;
5818
6943
  }
5819
6944
  const contentType = req.headers["content-type"] || "application/octet-stream";
5820
- sendJson(res, 200, ctx.files.put(safe, body, contentType));
6945
+ sendJson(res, 200, files.put(safe, body, contentType));
5821
6946
  return;
5822
6947
  }
5823
6948
  if (path.startsWith("/files/")) {
@@ -5827,7 +6952,7 @@ async function handleFiles(ctx, req, res, path, url) {
5827
6952
  return;
5828
6953
  }
5829
6954
  if (req.method === "GET") {
5830
- const found = ctx.files.get(name);
6955
+ const found = files.get(name);
5831
6956
  if (!found) {
5832
6957
  sendJson(res, 404, { detail: "File not found" });
5833
6958
  return;
@@ -5844,7 +6969,7 @@ async function handleFiles(ctx, req, res, path, url) {
5844
6969
  return;
5845
6970
  }
5846
6971
  if (req.method === "DELETE") {
5847
- ctx.files.delete(name);
6972
+ files.delete(name);
5848
6973
  sendStatus(res, 204);
5849
6974
  return;
5850
6975
  }
@@ -6042,6 +7167,12 @@ function devSafeName(name) {
6042
7167
  return null;
6043
7168
  if (name.split("/").some((seg) => seg === "" || seg === "." || seg === ".."))
6044
7169
  return null;
7170
+ // `users`/`roles` are reserved as the first path segment (mirrors the backend's
7171
+ // _safe_name): the user/role file subtrees nest under the shared prefix, so a
7172
+ // shared-scope name beginning with them would alias another scope's object.
7173
+ const first = name.split("/", 1)[0].toLowerCase();
7174
+ if (first === "users" || first === "roles")
7175
+ return null;
6045
7176
  return name;
6046
7177
  }
6047
7178
  function readBody(req) {