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/README.md +17 -0
- package/dist/connections.js +156 -0
- package/dist/index.js +1155 -24
- package/dist/observability.js +220 -0
- package/dist/orgadmin.js +227 -0
- package/package.json +1 -1
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> ...
|
|
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
|
-
|
|
987
|
-
|
|
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
|
-
|
|
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
|
|
5125
|
-
//
|
|
5126
|
-
//
|
|
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
|
-
|
|
5164
|
-
|
|
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
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
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
|
|
6587
|
+
return this.metaFile;
|
|
5573
6588
|
}
|
|
5574
6589
|
blobPath(name) {
|
|
5575
|
-
return join(this.
|
|
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 =
|
|
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 =
|
|
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,
|
|
6824
|
+
sendJson(res, 200, kv.put(collection, key, payload.value));
|
|
5738
6825
|
return;
|
|
5739
6826
|
}
|
|
5740
6827
|
if (req.method === "DELETE") {
|
|
5741
|
-
|
|
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:
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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) {
|