railcode 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/db.js +1 -1
- package/dist/index.js +368 -31
- package/package.json +1 -1
- package/static/sdk.js +135 -27
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ railcode agent <list|show|create|update|delete|run|schedule> ...
|
|
|
58
58
|
`railcode db list` (aliases `ls`, `connections`) prints each connector's
|
|
59
59
|
`name` + `engine` (`--json` for the raw array). `railcode db query "<sql>"`
|
|
60
60
|
(alias `sql`) runs SQL against `--connection` (default `default`) and prints a
|
|
61
|
-
table + row count; `--engine <postgres|bigquery|
|
|
61
|
+
table + row count; `--engine <postgres|bigquery|turso>` is inferred from the connector
|
|
62
62
|
list when omitted, `--params '<json-array>'` binds `$1, $2, …` placeholders
|
|
63
63
|
(SQL is never interpolated), `--file <path>` reads SQL from a file, and
|
|
64
64
|
`--json` prints the raw `{ columns, rows, rowcount, truncated }` envelope.
|
package/dist/db.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the command layer in index.ts does the HTTP, file reads, and printing.
|
|
4
4
|
export class DbError extends Error {
|
|
5
5
|
}
|
|
6
|
-
export const DB_ENGINES = ["postgres", "bigquery", "
|
|
6
|
+
export const DB_ENGINES = ["postgres", "bigquery", "turso"];
|
|
7
7
|
// `--params '<json-array>'` → the positional $1,$2,… values. Empty when omitted.
|
|
8
8
|
export function parseSqlParams(raw) {
|
|
9
9
|
if (raw === undefined)
|
package/dist/index.js
CHANGED
|
@@ -826,7 +826,7 @@ export default defineConfig({
|
|
|
826
826
|
}
|
|
827
827
|
function reactRailcodeTypes() {
|
|
828
828
|
return `type Me = {
|
|
829
|
-
user: { uuid: string; name: string; email: string };
|
|
829
|
+
user: { uuid: string; name: string; email: string; is_admin: boolean; roles: RoleRef[] };
|
|
830
830
|
app: { uuid: string; slug: string; name: string };
|
|
831
831
|
org: { uuid: string; slug: string; name: string };
|
|
832
832
|
};
|
|
@@ -835,9 +835,12 @@ type AppUser = {
|
|
|
835
835
|
uuid: string;
|
|
836
836
|
name: string;
|
|
837
837
|
email: string;
|
|
838
|
-
|
|
838
|
+
is_admin: boolean;
|
|
839
839
|
};
|
|
840
840
|
|
|
841
|
+
type RoleRef = { uuid: string; name: string };
|
|
842
|
+
type OrgRole = RoleRef & { description: string; is_member: boolean };
|
|
843
|
+
|
|
841
844
|
type KvRecord<T = unknown> = {
|
|
842
845
|
key: string;
|
|
843
846
|
value: T;
|
|
@@ -881,7 +884,7 @@ type SqlRows = Array<Record<string, unknown>> & {
|
|
|
881
884
|
};
|
|
882
885
|
|
|
883
886
|
type DataConnectorInfo = {
|
|
884
|
-
engine: "postgres" | "bigquery" | "
|
|
887
|
+
engine: "postgres" | "bigquery" | "turso" | string;
|
|
885
888
|
name: string;
|
|
886
889
|
};
|
|
887
890
|
|
|
@@ -960,20 +963,50 @@ type LlmModelInfo = { model: string; default: boolean };
|
|
|
960
963
|
|
|
961
964
|
type LlmProviderInfo = { provider: string; default: boolean; models: LlmModelInfo[] };
|
|
962
965
|
|
|
966
|
+
type EmailSendOptions = {
|
|
967
|
+
to: string | string[];
|
|
968
|
+
subject: string;
|
|
969
|
+
html?: string;
|
|
970
|
+
text?: string;
|
|
971
|
+
cc?: string | string[];
|
|
972
|
+
bcc?: string | string[];
|
|
973
|
+
replyTo?: string;
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
type EmailSendResult = {
|
|
977
|
+
id: string;
|
|
978
|
+
status: string;
|
|
979
|
+
requestId: string;
|
|
980
|
+
};
|
|
981
|
+
|
|
963
982
|
declare const me: () => Promise<Me>;
|
|
964
983
|
declare const appUsers: () => Promise<AppUser[]>;
|
|
984
|
+
declare const roles: () => Promise<OrgRole[]>;
|
|
965
985
|
declare const designSystem: () => Promise<string>;
|
|
966
|
-
|
|
967
|
-
|
|
986
|
+
type DbNamespace = { collection<T = unknown>(name: string): Collection<T> };
|
|
987
|
+
type FilesNamespace = {
|
|
968
988
|
upload(name: string, data: Blob | ArrayBuffer | ArrayBufferView, contentType?: string): Promise<FileMeta>;
|
|
969
989
|
url(name: string): string;
|
|
970
990
|
list(): Promise<FileMeta[]>;
|
|
971
991
|
delete(name: string): Promise<void>;
|
|
972
992
|
};
|
|
993
|
+
// Storage scopes. Bare db/files are aliases for \`.shared\` (app-wide). \`.user\` is the
|
|
994
|
+
// signed-in caller's own private namespace; \`.role(uuid)\` is one org role's namespace
|
|
995
|
+
// (the caller must be a live member of that role — owner/admin may reach any). The same
|
|
996
|
+
// (collection,key) / file name never collides across scopes.
|
|
997
|
+
declare const db: DbNamespace & {
|
|
998
|
+
shared: DbNamespace;
|
|
999
|
+
user: DbNamespace;
|
|
1000
|
+
role(uuid: string): DbNamespace;
|
|
1001
|
+
};
|
|
1002
|
+
declare const files: FilesNamespace & {
|
|
1003
|
+
shared: FilesNamespace;
|
|
1004
|
+
user: FilesNamespace;
|
|
1005
|
+
role(uuid: string): FilesNamespace;
|
|
1006
|
+
};
|
|
973
1007
|
declare const data: DatabaseNamespace;
|
|
974
1008
|
declare const postgres: DatabaseNamespace;
|
|
975
1009
|
declare const bigquery: DatabaseNamespace;
|
|
976
|
-
declare const snowflake: DatabaseNamespace;
|
|
977
1010
|
declare const dataConnectors: () => Promise<DataConnectorInfo[]>;
|
|
978
1011
|
declare const savedQueries: () => Promise<SavedQueryInfo[]>;
|
|
979
1012
|
declare const query: (name: string, params?: Record<string, unknown>) => Promise<SqlRows>;
|
|
@@ -986,6 +1019,7 @@ declare const llm: {
|
|
|
986
1019
|
stream(input: string | LlmMessage[], opts?: LlmOptions): AsyncIterable<LlmStreamEvent>;
|
|
987
1020
|
};
|
|
988
1021
|
declare const llmProviders: () => Promise<LlmProviderInfo[]>;
|
|
1022
|
+
declare const email: { send(opts: EmailSendOptions): Promise<EmailSendResult> };
|
|
989
1023
|
`;
|
|
990
1024
|
}
|
|
991
1025
|
function reactViteEnvTypes() {
|
|
@@ -1060,6 +1094,14 @@ function message(error: unknown): string {
|
|
|
1060
1094
|
return error instanceof Error ? error.message : String(error);
|
|
1061
1095
|
}
|
|
1062
1096
|
|
|
1097
|
+
function httpStatus(error: unknown): number | null {
|
|
1098
|
+
if (typeof error === "object" && error !== null && "status" in error) {
|
|
1099
|
+
const status = (error as { status?: unknown }).status;
|
|
1100
|
+
return typeof status === "number" ? status : null;
|
|
1101
|
+
}
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1063
1105
|
function truncate(value: string, max: number): string {
|
|
1064
1106
|
return value.length > max ? value.slice(0, max - 1) + "…" : value;
|
|
1065
1107
|
}
|
|
@@ -1361,7 +1403,7 @@ function Hero() {
|
|
|
1361
1403
|
</div>
|
|
1362
1404
|
) : null}
|
|
1363
1405
|
{teammates.slice(0, 4).map((user) => (
|
|
1364
|
-
<div className="avatar" key={user.uuid} title={user.name + (user.
|
|
1406
|
+
<div className="avatar" key={user.uuid} title={user.name + (user.is_admin ? " · admin" : "")}>
|
|
1365
1407
|
{initials(user.name)}
|
|
1366
1408
|
</div>
|
|
1367
1409
|
))}
|
|
@@ -1867,9 +1909,124 @@ function ConnectorsCard() {
|
|
|
1867
1909
|
);
|
|
1868
1910
|
}
|
|
1869
1911
|
|
|
1912
|
+
function emailFailureMessage(error: unknown): string {
|
|
1913
|
+
const status = httpStatus(error);
|
|
1914
|
+
if (status === 403) return "Email is not granted for this app or user yet.";
|
|
1915
|
+
if (status === 429) return "The daily email recipient limit has been reached.";
|
|
1916
|
+
if (status === 503) return "Email is not configured for this Railcode instance.";
|
|
1917
|
+
return message(error);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function EmailCard() {
|
|
1921
|
+
const { identity } = useAppStore();
|
|
1922
|
+
const [to, setTo] = useState("");
|
|
1923
|
+
const [subject, setSubject] = useState("Hello from " + APP_SLUG);
|
|
1924
|
+
const [body, setBody] = useState(
|
|
1925
|
+
"This message was sent by the Railcode starter app through the platform email gateway.",
|
|
1926
|
+
);
|
|
1927
|
+
const [busy, setBusy] = useState(false);
|
|
1928
|
+
const [status, setStatus] = useState<{ kind: "sent" | "error"; text: string } | null>(null);
|
|
1929
|
+
|
|
1930
|
+
useEffect(() => {
|
|
1931
|
+
if (!to && identity?.user.email) setTo(identity.user.email);
|
|
1932
|
+
}, [identity, to]);
|
|
1933
|
+
|
|
1934
|
+
async function sendMessage(event: FormEvent<HTMLFormElement>) {
|
|
1935
|
+
event.preventDefault();
|
|
1936
|
+
const recipient = to.trim();
|
|
1937
|
+
const trimmedSubject = subject.trim() || "Hello from " + APP_SLUG;
|
|
1938
|
+
const text = body.trim() || "Sent from a Railcode starter app.";
|
|
1939
|
+
if (!recipient) {
|
|
1940
|
+
setStatus({ kind: "error", text: "Add a recipient email address first." });
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
setBusy(true);
|
|
1945
|
+
setStatus(null);
|
|
1946
|
+
try {
|
|
1947
|
+
const result = await email.send({ to: recipient, subject: trimmedSubject, text });
|
|
1948
|
+
setStatus({
|
|
1949
|
+
kind: "sent",
|
|
1950
|
+
text: "Sent with status " + result.status + " · " + result.requestId,
|
|
1951
|
+
});
|
|
1952
|
+
} catch (error) {
|
|
1953
|
+
setStatus({ kind: "error", text: emailFailureMessage(error) });
|
|
1954
|
+
} finally {
|
|
1955
|
+
setBusy(false);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
return (
|
|
1960
|
+
<article className="card">
|
|
1961
|
+
<div className="card-head">
|
|
1962
|
+
<div>
|
|
1963
|
+
<div className="card-title-row">
|
|
1964
|
+
<Icon name="mail" />
|
|
1965
|
+
<span className="card-title">
|
|
1966
|
+
<Reveal code="await email.send(message)">Email</Reveal>
|
|
1967
|
+
</span>
|
|
1968
|
+
</div>
|
|
1969
|
+
</div>
|
|
1970
|
+
</div>
|
|
1971
|
+
|
|
1972
|
+
<form className="email-form" onSubmit={sendMessage}>
|
|
1973
|
+
<label className="control-field">
|
|
1974
|
+
<span>Recipient</span>
|
|
1975
|
+
<input
|
|
1976
|
+
className="field"
|
|
1977
|
+
type="email"
|
|
1978
|
+
autoComplete="email"
|
|
1979
|
+
value={to}
|
|
1980
|
+
onChange={(event) => setTo(event.target.value)}
|
|
1981
|
+
/>
|
|
1982
|
+
</label>
|
|
1983
|
+
<label className="control-field">
|
|
1984
|
+
<span>Subject</span>
|
|
1985
|
+
<input
|
|
1986
|
+
className="field"
|
|
1987
|
+
type="text"
|
|
1988
|
+
value={subject}
|
|
1989
|
+
onChange={(event) => setSubject(event.target.value)}
|
|
1990
|
+
/>
|
|
1991
|
+
</label>
|
|
1992
|
+
<label className="json-editor">
|
|
1993
|
+
<span>Message</span>
|
|
1994
|
+
<textarea
|
|
1995
|
+
className="field"
|
|
1996
|
+
rows={4}
|
|
1997
|
+
value={body}
|
|
1998
|
+
onChange={(event) => setBody(event.target.value)}
|
|
1999
|
+
/>
|
|
2000
|
+
</label>
|
|
2001
|
+
<ActionTip code={\`await email.send({
|
|
2002
|
+
to,
|
|
2003
|
+
subject,
|
|
2004
|
+
text
|
|
2005
|
+
})\`} className="upload-tip">
|
|
2006
|
+
<button className="btn btn-primary" type="submit" disabled={busy}>
|
|
2007
|
+
{busy ? <span className="spinner" /> : <Icon name="mail" />}
|
|
2008
|
+
{busy ? "Sending..." : "Send test email"}
|
|
2009
|
+
</button>
|
|
2010
|
+
</ActionTip>
|
|
2011
|
+
</form>
|
|
2012
|
+
|
|
2013
|
+
<div className="email-state">
|
|
2014
|
+
<div className="result-status">
|
|
2015
|
+
<span>Delivery</span>
|
|
2016
|
+
<span>{status?.kind === "sent" ? "Sent" : status?.kind === "error" ? "Needs setup" : "Ready"}</span>
|
|
2017
|
+
</div>
|
|
2018
|
+
<p className={status?.kind === "error" ? "email-note is-error" : "email-note"}>
|
|
2019
|
+
{status?.text ||
|
|
2020
|
+
"Email uses the platform sender and requires an email grant or app manifest approval."}
|
|
2021
|
+
</p>
|
|
2022
|
+
</div>
|
|
2023
|
+
</article>
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
1870
2027
|
const AI_SAMPLE_PROMPT = "Summarize what this Railcode template demonstrates in one sentence.";
|
|
1871
2028
|
const AI_SAMPLE_RESPONSE =
|
|
1872
|
-
"This template shows a live Railcode app reading identity, storing notes in KV, uploading files, querying Postgres, invoking a saved query, calling Stripe through a service connector, and generating text — all without handling a single credential in the browser.";
|
|
2029
|
+
"This template shows a live Railcode app reading identity, storing notes in KV, uploading files, querying Postgres, invoking a saved query, calling Stripe through a service connector, sending email, and generating text — all without handling a single credential in the browser.";
|
|
1873
2030
|
|
|
1874
2031
|
function AiCard() {
|
|
1875
2032
|
const [prompt, setPrompt] = useState(AI_SAMPLE_PROMPT);
|
|
@@ -2010,6 +2167,10 @@ function IconSprite() {
|
|
|
2010
2167
|
<symbol id="i-chat" viewBox="0 0 24 24">
|
|
2011
2168
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
2012
2169
|
</symbol>
|
|
2170
|
+
<symbol id="i-mail" viewBox="0 0 24 24">
|
|
2171
|
+
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
2172
|
+
<path d="m3 7 9 6 9-6" />
|
|
2173
|
+
</symbol>
|
|
2013
2174
|
<symbol id="i-code" viewBox="0 0 24 24">
|
|
2014
2175
|
<polyline points="16 18 22 12 16 6" />
|
|
2015
2176
|
<polyline points="8 6 2 12 8 18" />
|
|
@@ -2076,11 +2237,18 @@ export function App() {
|
|
|
2076
2237
|
|
|
2077
2238
|
<GuideSection
|
|
2078
2239
|
title="They can connect to external services"
|
|
2079
|
-
body="Railcode apps can securely connect to your SaaS services so you can manage refunds,
|
|
2240
|
+
body="Railcode apps can securely connect to your SaaS services so you can manage refunds, sync tickets, enrich records, and anything else you need."
|
|
2080
2241
|
>
|
|
2081
2242
|
<ConnectorsCard />
|
|
2082
2243
|
</GuideSection>
|
|
2083
2244
|
|
|
2245
|
+
<GuideSection
|
|
2246
|
+
title="They can send email"
|
|
2247
|
+
body="Send transactional email through the Railcode gateway without putting provider credentials in browser code."
|
|
2248
|
+
>
|
|
2249
|
+
<EmailCard />
|
|
2250
|
+
</GuideSection>
|
|
2251
|
+
|
|
2084
2252
|
<GuideSection
|
|
2085
2253
|
title="They can use AI"
|
|
2086
2254
|
body="Build AI-native apps that use LLMs to enable powerful workflows."
|
|
@@ -3101,6 +3269,41 @@ th:first-child {
|
|
|
3101
3269
|
}
|
|
3102
3270
|
}
|
|
3103
3271
|
|
|
3272
|
+
/* ---------- email card ---------- */
|
|
3273
|
+
|
|
3274
|
+
.email-form {
|
|
3275
|
+
display: flex;
|
|
3276
|
+
flex-direction: column;
|
|
3277
|
+
gap: var(--space-3);
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
.email-form .field {
|
|
3281
|
+
width: 100%;
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
.email-state {
|
|
3285
|
+
margin-top: var(--space-4);
|
|
3286
|
+
padding: 12px 14px;
|
|
3287
|
+
border: 1px solid var(--line);
|
|
3288
|
+
border-radius: var(--radius-md);
|
|
3289
|
+
background: var(--bg-inset);
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
.email-state .result-status {
|
|
3293
|
+
margin-bottom: 6px;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
.email-note {
|
|
3297
|
+
color: var(--ink-3);
|
|
3298
|
+
font-size: 12.5px;
|
|
3299
|
+
line-height: 1.55;
|
|
3300
|
+
overflow-wrap: anywhere;
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
.email-note.is-error {
|
|
3304
|
+
color: #b42318;
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3104
3307
|
/* ---------- reveal + action tooltips ---------- */
|
|
3105
3308
|
|
|
3106
3309
|
.reveal {
|
|
@@ -4102,7 +4305,7 @@ List options:
|
|
|
4102
4305
|
|
|
4103
4306
|
Query options:
|
|
4104
4307
|
--connection <name> Connector to query (default: default)
|
|
4105
|
-
--engine <postgres|
|
|
4308
|
+
--engine <postgres|bigquery|turso> Engine override (inferred from --connection otherwise)
|
|
4106
4309
|
--params '<json-array>' Values for $1, $2, … placeholders
|
|
4107
4310
|
--file <path> Read SQL from a file instead of the argument
|
|
4108
4311
|
--json Print the raw { columns, rows, rowcount, truncated } envelope
|
|
@@ -4932,9 +5135,10 @@ class CliError extends Error {
|
|
|
4932
5135
|
// dev — local development server (`railcode dev`)
|
|
4933
5136
|
//
|
|
4934
5137
|
// Serves the app on a single loopback origin and emulates the /_api/* data plane:
|
|
4935
|
-
// identity is synthetic, KV + files live on local disk
|
|
4936
|
-
//
|
|
4937
|
-
//
|
|
5138
|
+
// identity is synthetic, KV + files live on local disk (isolated per storage scope —
|
|
5139
|
+
// shared/user/role — so `db.user`/`files.role(uuid)` behave like prod). llm/sql/queries/
|
|
5140
|
+
// connectors/email proxy to the real instance as the signed-in user (their token + org
|
|
5141
|
+
// grants) via the org/user-scoped routes — no deploy needed. Mirrors railcode-core's dev.
|
|
4938
5142
|
// ---------------------------------------------------------------------------
|
|
4939
5143
|
const DEFAULT_DEV_PORT = 7331;
|
|
4940
5144
|
const DEFAULT_ASSET_PORT = 5173;
|
|
@@ -4971,8 +5175,9 @@ async function commandDev(args) {
|
|
|
4971
5175
|
app: manifest.app,
|
|
4972
5176
|
root: plan.root,
|
|
4973
5177
|
sdkPath,
|
|
4974
|
-
|
|
4975
|
-
|
|
5178
|
+
stateDir,
|
|
5179
|
+
kvStores: new Map(),
|
|
5180
|
+
fileStores: new Map(),
|
|
4976
5181
|
port: requestedPort,
|
|
4977
5182
|
config,
|
|
4978
5183
|
};
|
|
@@ -5375,15 +5580,21 @@ class DevKvStore {
|
|
|
5375
5580
|
}
|
|
5376
5581
|
// ── local file store (bytes on disk, metadata in files.json) ──────────────────
|
|
5377
5582
|
class DevFileStore {
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5583
|
+
blobRoot;
|
|
5584
|
+
metaFile;
|
|
5585
|
+
// Scope-aware, mirroring the backend's physical layout: shared keeps the legacy
|
|
5586
|
+
// `files/` blob dir + `files.json` meta (byte-compatible with pre-scopes dev state);
|
|
5587
|
+
// user/role nest under a per-scope blob dir + meta so namespaces never collide.
|
|
5588
|
+
constructor(dir, scopeSlug) {
|
|
5589
|
+
this.blobRoot = scopeSlug ? join(dir, `files-${scopeSlug}`) : join(dir, "files");
|
|
5590
|
+
this.metaFile = scopeSlug ? join(dir, `files.${scopeSlug}.json`) : join(dir, "files.json");
|
|
5591
|
+
mkdirSync(this.blobRoot, { recursive: true });
|
|
5381
5592
|
}
|
|
5382
5593
|
metaPath() {
|
|
5383
|
-
return
|
|
5594
|
+
return this.metaFile;
|
|
5384
5595
|
}
|
|
5385
5596
|
blobPath(name) {
|
|
5386
|
-
return join(this.
|
|
5597
|
+
return join(this.blobRoot, encodeURIComponent(name));
|
|
5387
5598
|
}
|
|
5388
5599
|
readMeta() {
|
|
5389
5600
|
const p = this.metaPath();
|
|
@@ -5453,6 +5664,10 @@ async function handleLocalApi(ctx, req, res, url) {
|
|
|
5453
5664
|
sendJson(res, 200, [appUserEnvelope()]);
|
|
5454
5665
|
return;
|
|
5455
5666
|
}
|
|
5667
|
+
if (path === "/roles") {
|
|
5668
|
+
sendJson(res, 200, []);
|
|
5669
|
+
return;
|
|
5670
|
+
}
|
|
5456
5671
|
if (path === "/config/design-system") {
|
|
5457
5672
|
await handleDevDesignSystem(ctx, res);
|
|
5458
5673
|
return;
|
|
@@ -5482,7 +5697,79 @@ async function handleLocalApi(ctx, req, res, url) {
|
|
|
5482
5697
|
}
|
|
5483
5698
|
sendJson(res, 404, { detail: "not found" });
|
|
5484
5699
|
}
|
|
5700
|
+
// role/user selectors are UUIDs on the backend (it resolves them to FK ids). Enforce
|
|
5701
|
+
// the same here so a selector can NEVER reach the filesystem as a path segment — the
|
|
5702
|
+
// slug feeds kv.<slug>.json / files-<slug>/, so an unvalidated "../.." would traverse
|
|
5703
|
+
// out of the dev state dir. UUIDs contain only hex + hyphens, so this closes it.
|
|
5704
|
+
const DEV_UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
5705
|
+
function resolveDevScope(url) {
|
|
5706
|
+
const scope = (url.searchParams.get("scope") || "shared").toLowerCase();
|
|
5707
|
+
const role = url.searchParams.get("role");
|
|
5708
|
+
const user = url.searchParams.get("user");
|
|
5709
|
+
if (scope === "shared") {
|
|
5710
|
+
if (role || user) {
|
|
5711
|
+
return { ok: false, status: 400, detail: "shared scope takes no role or user selector" };
|
|
5712
|
+
}
|
|
5713
|
+
return { ok: true, scope: { key: "shared", slug: "shared" } };
|
|
5714
|
+
}
|
|
5715
|
+
if (scope === "user") {
|
|
5716
|
+
if (role)
|
|
5717
|
+
return { ok: false, status: 400, detail: "user scope takes no role selector" };
|
|
5718
|
+
if (user !== null && !DEV_UUID_RE.test(user)) {
|
|
5719
|
+
return { ok: false, status: 400, detail: "malformed user id" };
|
|
5720
|
+
}
|
|
5721
|
+
const uuid = user || DEV_USER_UUID;
|
|
5722
|
+
return { ok: true, scope: { key: `user:${uuid}`, slug: `users-${uuid}` } };
|
|
5723
|
+
}
|
|
5724
|
+
if (scope === "role") {
|
|
5725
|
+
if (user)
|
|
5726
|
+
return { ok: false, status: 400, detail: "role scope takes no user selector" };
|
|
5727
|
+
if (!role)
|
|
5728
|
+
return { ok: false, status: 400, detail: "role scope requires a role id" };
|
|
5729
|
+
if (!DEV_UUID_RE.test(role))
|
|
5730
|
+
return { ok: false, status: 400, detail: "malformed role id" };
|
|
5731
|
+
return { ok: true, scope: { key: `role:${role}`, slug: `roles-${role}` } };
|
|
5732
|
+
}
|
|
5733
|
+
return { ok: false, status: 400, detail: `invalid scope: ${scope}` };
|
|
5734
|
+
}
|
|
5735
|
+
function kvForScope(ctx, scope) {
|
|
5736
|
+
let store = ctx.kvStores.get(scope.key);
|
|
5737
|
+
if (!store) {
|
|
5738
|
+
const path = scope.key === "shared"
|
|
5739
|
+
? join(ctx.stateDir, "kv.json")
|
|
5740
|
+
: join(ctx.stateDir, `kv.${scope.slug}.json`);
|
|
5741
|
+
store = new DevKvStore(path);
|
|
5742
|
+
ctx.kvStores.set(scope.key, store);
|
|
5743
|
+
}
|
|
5744
|
+
return store;
|
|
5745
|
+
}
|
|
5746
|
+
function filesForScope(ctx, scope) {
|
|
5747
|
+
let store = ctx.fileStores.get(scope.key);
|
|
5748
|
+
if (!store) {
|
|
5749
|
+
store = new DevFileStore(ctx.stateDir, scope.key === "shared" ? undefined : scope.slug);
|
|
5750
|
+
ctx.fileStores.set(scope.key, store);
|
|
5751
|
+
}
|
|
5752
|
+
return store;
|
|
5753
|
+
}
|
|
5754
|
+
// Re-attach the request's scope selectors to a same-origin /_api URL (the local-proxy
|
|
5755
|
+
// blob upload URL, download URLs) so the follow-up call resolves the identical scope —
|
|
5756
|
+
// mirrors the backend's _scoped_url.
|
|
5757
|
+
function scopeQuerySuffix(url) {
|
|
5758
|
+
const parts = [];
|
|
5759
|
+
for (const k of ["scope", "role", "user"]) {
|
|
5760
|
+
const v = url.searchParams.get(k);
|
|
5761
|
+
if (v)
|
|
5762
|
+
parts.push(`${k}=${encodeURIComponent(v)}`);
|
|
5763
|
+
}
|
|
5764
|
+
return parts.length ? `&${parts.join("&")}` : "";
|
|
5765
|
+
}
|
|
5485
5766
|
async function handleKv(ctx, req, res, path, url) {
|
|
5767
|
+
const resolved = resolveDevScope(url);
|
|
5768
|
+
if (!resolved.ok) {
|
|
5769
|
+
sendJson(res, resolved.status, { detail: resolved.detail });
|
|
5770
|
+
return;
|
|
5771
|
+
}
|
|
5772
|
+
const kv = kvForScope(ctx, resolved.scope);
|
|
5486
5773
|
// path: /kv/{collection} | /kv/{collection}/{key...}
|
|
5487
5774
|
const rest = path.slice("/kv/".length);
|
|
5488
5775
|
const slash = rest.indexOf("/");
|
|
@@ -5502,7 +5789,7 @@ async function handleKv(ctx, req, res, path, url) {
|
|
|
5502
5789
|
return;
|
|
5503
5790
|
}
|
|
5504
5791
|
try {
|
|
5505
|
-
const records =
|
|
5792
|
+
const records = kv.records(collection);
|
|
5506
5793
|
const params = parseKvParams(url);
|
|
5507
5794
|
if (url.searchParams.get("count") === "true") {
|
|
5508
5795
|
sendJson(res, 200, { count: kvCount(records, params) });
|
|
@@ -5520,7 +5807,7 @@ async function handleKv(ctx, req, res, path, url) {
|
|
|
5520
5807
|
return;
|
|
5521
5808
|
}
|
|
5522
5809
|
if (req.method === "GET") {
|
|
5523
|
-
const rec =
|
|
5810
|
+
const rec = kv.get(collection, key);
|
|
5524
5811
|
if (!rec)
|
|
5525
5812
|
sendJson(res, 404, { detail: "Key not found" });
|
|
5526
5813
|
else
|
|
@@ -5541,11 +5828,11 @@ async function handleKv(ctx, req, res, path, url) {
|
|
|
5541
5828
|
sendJson(res, 413, { detail: "Value too large" });
|
|
5542
5829
|
return;
|
|
5543
5830
|
}
|
|
5544
|
-
sendJson(res, 200,
|
|
5831
|
+
sendJson(res, 200, kv.put(collection, key, payload.value));
|
|
5545
5832
|
return;
|
|
5546
5833
|
}
|
|
5547
5834
|
if (req.method === "DELETE") {
|
|
5548
|
-
|
|
5835
|
+
kv.delete(collection, key);
|
|
5549
5836
|
sendStatus(res, 204);
|
|
5550
5837
|
return;
|
|
5551
5838
|
}
|
|
@@ -5583,8 +5870,44 @@ function parseKvParams(url) {
|
|
|
5583
5870
|
};
|
|
5584
5871
|
}
|
|
5585
5872
|
async function handleFiles(ctx, req, res, path, url) {
|
|
5873
|
+
const resolved = resolveDevScope(url);
|
|
5874
|
+
if (!resolved.ok) {
|
|
5875
|
+
sendJson(res, resolved.status, { detail: resolved.detail });
|
|
5876
|
+
return;
|
|
5877
|
+
}
|
|
5878
|
+
const files = filesForScope(ctx, resolved.scope);
|
|
5879
|
+
const scopeSuffix = scopeQuerySuffix(url);
|
|
5586
5880
|
if (path === "/files" && req.method === "GET") {
|
|
5587
|
-
sendJson(res, 200, { items:
|
|
5881
|
+
sendJson(res, 200, { items: files.list() });
|
|
5882
|
+
return;
|
|
5883
|
+
}
|
|
5884
|
+
if (path === "/files/urls" && req.method === "POST") {
|
|
5885
|
+
const body = await readBody(req);
|
|
5886
|
+
let payload;
|
|
5887
|
+
try {
|
|
5888
|
+
payload = JSON.parse(body.toString("utf8") || "{}");
|
|
5889
|
+
}
|
|
5890
|
+
catch {
|
|
5891
|
+
sendJson(res, 400, { detail: "invalid JSON body" });
|
|
5892
|
+
return;
|
|
5893
|
+
}
|
|
5894
|
+
const names = Array.isArray(payload.names)
|
|
5895
|
+
? payload.names.filter((n) => typeof n === "string")
|
|
5896
|
+
: [];
|
|
5897
|
+
// Local proxy: download URLs carry the scope so a later GET re-resolves it.
|
|
5898
|
+
const scopeQuery = scopeSuffix ? `?${scopeSuffix.slice(1)}` : "";
|
|
5899
|
+
const items = [];
|
|
5900
|
+
const missing = [];
|
|
5901
|
+
for (const name of [...new Set(names)]) {
|
|
5902
|
+
const safe = devSafeName(name);
|
|
5903
|
+
if (safe && files.get(safe)) {
|
|
5904
|
+
items.push({ name, url: `/_api/files/${encodeURIComponent(safe)}${scopeQuery}`, expires_in: null });
|
|
5905
|
+
}
|
|
5906
|
+
else {
|
|
5907
|
+
missing.push(name);
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
sendJson(res, 200, { items, missing });
|
|
5588
5911
|
return;
|
|
5589
5912
|
}
|
|
5590
5913
|
if (path === "/files" && req.method === "POST") {
|
|
@@ -5602,10 +5925,12 @@ async function handleFiles(ctx, req, res, path, url) {
|
|
|
5602
5925
|
sendJson(res, 400, { detail: "Invalid file name" });
|
|
5603
5926
|
return;
|
|
5604
5927
|
}
|
|
5928
|
+
// The blob URL carries the scope selectors so the follow-up PUT lands in the
|
|
5929
|
+
// same scope (mirrors the backend's _scoped_url on the proxy path).
|
|
5605
5930
|
sendJson(res, 200, {
|
|
5606
5931
|
mode: "proxy",
|
|
5607
5932
|
method: "PUT",
|
|
5608
|
-
url: `/_api/files/blob?name=${encodeURIComponent(safe)}`,
|
|
5933
|
+
url: `/_api/files/blob?name=${encodeURIComponent(safe)}${scopeSuffix}`,
|
|
5609
5934
|
object_key: safe,
|
|
5610
5935
|
headers: {},
|
|
5611
5936
|
expires_in: null,
|
|
@@ -5624,7 +5949,7 @@ async function handleFiles(ctx, req, res, path, url) {
|
|
|
5624
5949
|
return;
|
|
5625
5950
|
}
|
|
5626
5951
|
const contentType = req.headers["content-type"] || "application/octet-stream";
|
|
5627
|
-
sendJson(res, 200,
|
|
5952
|
+
sendJson(res, 200, files.put(safe, body, contentType));
|
|
5628
5953
|
return;
|
|
5629
5954
|
}
|
|
5630
5955
|
if (path.startsWith("/files/")) {
|
|
@@ -5634,7 +5959,7 @@ async function handleFiles(ctx, req, res, path, url) {
|
|
|
5634
5959
|
return;
|
|
5635
5960
|
}
|
|
5636
5961
|
if (req.method === "GET") {
|
|
5637
|
-
const found =
|
|
5962
|
+
const found = files.get(name);
|
|
5638
5963
|
if (!found) {
|
|
5639
5964
|
sendJson(res, 404, { detail: "File not found" });
|
|
5640
5965
|
return;
|
|
@@ -5651,7 +5976,7 @@ async function handleFiles(ctx, req, res, path, url) {
|
|
|
5651
5976
|
return;
|
|
5652
5977
|
}
|
|
5653
5978
|
if (req.method === "DELETE") {
|
|
5654
|
-
|
|
5979
|
+
files.delete(name);
|
|
5655
5980
|
sendStatus(res, 204);
|
|
5656
5981
|
return;
|
|
5657
5982
|
}
|
|
@@ -5803,13 +6128,19 @@ async function handleDevDesignSystem(ctx, res) {
|
|
|
5803
6128
|
// ── envelopes + http helpers ──────────────────────────────────────────────────
|
|
5804
6129
|
function meEnvelope(ctx) {
|
|
5805
6130
|
return {
|
|
5806
|
-
user: {
|
|
6131
|
+
user: {
|
|
6132
|
+
uuid: DEV_USER_UUID,
|
|
6133
|
+
name: "Local Dev",
|
|
6134
|
+
email: "dev@localhost",
|
|
6135
|
+
is_admin: true,
|
|
6136
|
+
roles: [],
|
|
6137
|
+
},
|
|
5807
6138
|
app: { uuid: DEV_APP_UUID, slug: ctx.app, name: titleCase(ctx.app) },
|
|
5808
6139
|
org: { uuid: DEV_ORG_UUID, slug: "local", name: "Local" },
|
|
5809
6140
|
};
|
|
5810
6141
|
}
|
|
5811
6142
|
function appUserEnvelope() {
|
|
5812
|
-
return { uuid: DEV_USER_UUID, name: "Local Dev", email: "dev@localhost",
|
|
6143
|
+
return { uuid: DEV_USER_UUID, name: "Local Dev", email: "dev@localhost", is_admin: true };
|
|
5813
6144
|
}
|
|
5814
6145
|
function isLoopbackHost(host) {
|
|
5815
6146
|
if (!host)
|
|
@@ -5843,6 +6174,12 @@ function devSafeName(name) {
|
|
|
5843
6174
|
return null;
|
|
5844
6175
|
if (name.split("/").some((seg) => seg === "" || seg === "." || seg === ".."))
|
|
5845
6176
|
return null;
|
|
6177
|
+
// `users`/`roles` are reserved as the first path segment (mirrors the backend's
|
|
6178
|
+
// _safe_name): the user/role file subtrees nest under the shared prefix, so a
|
|
6179
|
+
// shared-scope name beginning with them would alias another scope's object.
|
|
6180
|
+
const first = name.split("/", 1)[0].toLowerCase();
|
|
6181
|
+
if (first === "users" || first === "roles")
|
|
6182
|
+
return null;
|
|
5846
6183
|
return name;
|
|
5847
6184
|
}
|
|
5848
6185
|
function readBody(req) {
|
package/package.json
CHANGED
package/static/sdk.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
padding:2px 6px; border-radius:4px; white-space:nowrap; align-self:start; margin-top:2px; }
|
|
36
36
|
.k-sql { background:#1e3a5f; color:#7cc4ff; } .k-db { background:#3a2752; color:#c79bff; }
|
|
37
37
|
.k-files { background:#13413c; color:#5fd6c4; } .k-llm { background:#4a2b16; color:#f6b26b; }
|
|
38
|
-
.k-me, .k-connections, .k-app-users { background:#2a2f38; color:#9aa4b2; }
|
|
38
|
+
.k-me, .k-connections, .k-app-users, .k-roles { background:#2a2f38; color:#9aa4b2; }
|
|
39
39
|
.body { min-width:0; }
|
|
40
40
|
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size:11.5px; color:#e6e8eb;
|
|
41
41
|
word-break:break-word; display:block; line-height:1.45; }
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
<button id="collapse">Hide \u203A</button>
|
|
55
55
|
</header>
|
|
56
56
|
<div class="log" id="log"></div>
|
|
57
|
-
<footer>Calls made via <code>postgres()</code>, <code>llm</code>, <code>db</code>, <code>files</code>, <code>me()</code>, <code>appUsers()</code></footer>
|
|
57
|
+
<footer>Calls made via <code>postgres()</code>, <code>llm</code>, <code>db</code>, <code>files</code>, <code>me()</code>, <code>appUsers()</code>, <code>roles()</code></footer>
|
|
58
58
|
</div>`;
|
|
59
59
|
function mount() {
|
|
60
60
|
(document.body || document.documentElement).appendChild(host);
|
|
@@ -265,7 +265,7 @@
|
|
|
265
265
|
var data = namespace();
|
|
266
266
|
var postgres = namespace("postgres");
|
|
267
267
|
var bigquery = namespace("bigquery");
|
|
268
|
-
var
|
|
268
|
+
var turso = namespace("turso");
|
|
269
269
|
var dataConnectors = () => track("connections", "dataConnectors()", () => call("GET", "/connections"));
|
|
270
270
|
|
|
271
271
|
// ../sdk/src/util.ts
|
|
@@ -277,9 +277,18 @@
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
// ../sdk/src/db.ts
|
|
280
|
+
function scopeParams(s) {
|
|
281
|
+
return { scope: s.scope, role: s.role };
|
|
282
|
+
}
|
|
283
|
+
function scopeLabel(s) {
|
|
284
|
+
if (s.scope === "user") return "db.user";
|
|
285
|
+
if (s.scope === "role") return `db.role(${q(s.role ?? "")})`;
|
|
286
|
+
return "db.shared";
|
|
287
|
+
}
|
|
280
288
|
var Query = class {
|
|
281
|
-
constructor(collection) {
|
|
289
|
+
constructor(collection, scope) {
|
|
282
290
|
this.collection = collection;
|
|
291
|
+
this.scope = scope;
|
|
283
292
|
this._where = [];
|
|
284
293
|
}
|
|
285
294
|
where(field, op, value) {
|
|
@@ -309,11 +318,12 @@
|
|
|
309
318
|
prefix: this._prefix,
|
|
310
319
|
updated_since: this._since,
|
|
311
320
|
updated_before: this._before,
|
|
321
|
+
...scopeParams(this.scope),
|
|
312
322
|
...extra
|
|
313
323
|
};
|
|
314
324
|
}
|
|
315
325
|
label(method) {
|
|
316
|
-
return
|
|
326
|
+
return `${scopeLabel(this.scope)}.collection(${q(this.collection)}).${method}`;
|
|
317
327
|
}
|
|
318
328
|
async fetchPage(page, size) {
|
|
319
329
|
const resp = await call("GET", `/kv/${encPath(this.collection)}`, {
|
|
@@ -340,16 +350,20 @@
|
|
|
340
350
|
}
|
|
341
351
|
};
|
|
342
352
|
var Collection = class {
|
|
343
|
-
constructor(name) {
|
|
353
|
+
constructor(name, scope) {
|
|
344
354
|
this.name = name;
|
|
355
|
+
this.scope = scope;
|
|
345
356
|
}
|
|
346
357
|
label(method) {
|
|
347
|
-
return
|
|
358
|
+
return `${scopeLabel(this.scope)}.collection(${q(this.name)}).${method}`;
|
|
348
359
|
}
|
|
349
360
|
/** The stored value for ``key``, or null if absent. */
|
|
350
361
|
get(key) {
|
|
351
362
|
return track("db", this.label(`get(${q(key)})`), async () => {
|
|
352
|
-
const record = await getOrNull(
|
|
363
|
+
const record = await getOrNull(
|
|
364
|
+
`/kv/${encPath(this.name)}/${encPath(key)}`,
|
|
365
|
+
scopeParams(this.scope)
|
|
366
|
+
);
|
|
353
367
|
return record ? record.value : null;
|
|
354
368
|
});
|
|
355
369
|
}
|
|
@@ -357,7 +371,8 @@
|
|
|
357
371
|
put(key, value) {
|
|
358
372
|
return track("db", this.label(`put(${q(key)}, ${shorten(JSON.stringify(value), 40)})`), async () => {
|
|
359
373
|
const record = await call("PUT", `/kv/${encPath(this.name)}/${encPath(key)}`, {
|
|
360
|
-
body: { value }
|
|
374
|
+
body: { value },
|
|
375
|
+
query: scopeParams(this.scope)
|
|
361
376
|
});
|
|
362
377
|
return record.value;
|
|
363
378
|
});
|
|
@@ -366,18 +381,22 @@
|
|
|
366
381
|
return track(
|
|
367
382
|
"db",
|
|
368
383
|
this.label(`delete(${q(key)})`),
|
|
369
|
-
() => call("DELETE", `/kv/${encPath(this.name)}/${encPath(key)}
|
|
384
|
+
() => call("DELETE", `/kv/${encPath(this.name)}/${encPath(key)}`, {
|
|
385
|
+
query: scopeParams(this.scope)
|
|
386
|
+
})
|
|
370
387
|
);
|
|
371
388
|
}
|
|
372
389
|
/** All records in the collection (first page). */
|
|
373
390
|
list() {
|
|
374
391
|
return track("db", this.label("list()"), async () => {
|
|
375
|
-
const resp = await call("GET", `/kv/${encPath(this.name)}
|
|
392
|
+
const resp = await call("GET", `/kv/${encPath(this.name)}`, {
|
|
393
|
+
query: scopeParams(this.scope)
|
|
394
|
+
});
|
|
376
395
|
return resp.items;
|
|
377
396
|
});
|
|
378
397
|
}
|
|
379
398
|
query() {
|
|
380
|
-
return new Query(this.name);
|
|
399
|
+
return new Query(this.name, this.scope);
|
|
381
400
|
}
|
|
382
401
|
where(field, op, value) {
|
|
383
402
|
return this.query().where(field, op, value);
|
|
@@ -386,8 +405,15 @@
|
|
|
386
405
|
return this.query().prefix(value);
|
|
387
406
|
}
|
|
388
407
|
};
|
|
408
|
+
function namespace2(scope) {
|
|
409
|
+
return { collection: (name) => new Collection(name, scope) };
|
|
410
|
+
}
|
|
411
|
+
var shared = namespace2({});
|
|
389
412
|
var db = {
|
|
390
|
-
|
|
413
|
+
shared,
|
|
414
|
+
user: namespace2({ scope: "user" }),
|
|
415
|
+
role: (roleUuid) => namespace2({ scope: "role", role: roleUuid }),
|
|
416
|
+
collection: shared.collection
|
|
391
417
|
};
|
|
392
418
|
|
|
393
419
|
// ../sdk/src/design-system.ts
|
|
@@ -423,18 +449,41 @@
|
|
|
423
449
|
var email = { send: send2 };
|
|
424
450
|
|
|
425
451
|
// ../sdk/src/files.ts
|
|
452
|
+
var URL_EXPIRY_MARGIN_MS = 15e3;
|
|
453
|
+
var urlCache = /* @__PURE__ */ new Map();
|
|
454
|
+
function scopeParams2(s) {
|
|
455
|
+
return { scope: s.scope, role: s.role };
|
|
456
|
+
}
|
|
457
|
+
function scopeKey(s) {
|
|
458
|
+
if (s.scope === "user") return "user";
|
|
459
|
+
if (s.scope === "role") return `role:${s.role ?? ""}`;
|
|
460
|
+
return "shared";
|
|
461
|
+
}
|
|
462
|
+
function scopeLabel2(s) {
|
|
463
|
+
if (s.scope === "user") return "files.user";
|
|
464
|
+
if (s.scope === "role") return `files.role(${q(s.role ?? "")})`;
|
|
465
|
+
return "files.shared";
|
|
466
|
+
}
|
|
467
|
+
function cacheKey(s, name) {
|
|
468
|
+
return `${scopeKey(s)} ${name}`;
|
|
469
|
+
}
|
|
426
470
|
function contentTypeOf(data2, explicit) {
|
|
427
471
|
if (explicit) return explicit;
|
|
428
472
|
if (data2 instanceof Blob && data2.type) return data2.type;
|
|
429
473
|
return "application/octet-stream";
|
|
430
474
|
}
|
|
431
|
-
function upload(name, data2, contentType) {
|
|
432
|
-
return track("files",
|
|
475
|
+
function upload(scope, name, data2, contentType) {
|
|
476
|
+
return track("files", `${scopeLabel2(scope)}.upload(${q(name)}, <blob>)`, async () => {
|
|
477
|
+
const result = await doUpload(scope, name, data2, contentType);
|
|
478
|
+
urlCache.delete(cacheKey(scope, name));
|
|
479
|
+
return result;
|
|
480
|
+
});
|
|
433
481
|
}
|
|
434
|
-
async function doUpload(name, data2, contentType) {
|
|
482
|
+
async function doUpload(scope, name, data2, contentType) {
|
|
435
483
|
const ct = contentTypeOf(data2, contentType);
|
|
436
484
|
const target2 = await call("POST", "/files", {
|
|
437
|
-
body: { name, content_type: ct }
|
|
485
|
+
body: { name, content_type: ct },
|
|
486
|
+
query: scopeParams2(scope)
|
|
438
487
|
});
|
|
439
488
|
const headers = { "Content-Type": ct, ...target2.headers };
|
|
440
489
|
if (target2.mode === "proxy") {
|
|
@@ -449,21 +498,79 @@
|
|
|
449
498
|
}
|
|
450
499
|
const resp = await fetch(target2.url, { method: target2.method, body: data2, headers });
|
|
451
500
|
if (!resp.ok) throw new Error(`upload failed: ${resp.status}`);
|
|
452
|
-
return call("POST", "/files/finalize", { body: { name } });
|
|
501
|
+
return call("POST", "/files/finalize", { body: { name }, query: scopeParams2(scope) });
|
|
453
502
|
}
|
|
454
|
-
function url(name) {
|
|
455
|
-
return buildUrl(`/files/${encPath(name)}
|
|
503
|
+
function url(scope, name) {
|
|
504
|
+
return buildUrl(`/files/${encPath(name)}`, scopeParams2(scope));
|
|
456
505
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
506
|
+
async function urls(scope, names) {
|
|
507
|
+
return track("files", `${scopeLabel2(scope)}.urls(<${names.length} names>)`, async () => {
|
|
508
|
+
const unique = [...new Set(names)];
|
|
509
|
+
const now = Date.now();
|
|
510
|
+
const items = /* @__PURE__ */ new Map();
|
|
511
|
+
const unresolved = [];
|
|
512
|
+
for (const name of unique) {
|
|
513
|
+
const cached = urlCache.get(cacheKey(scope, name));
|
|
514
|
+
if (cached && cached.expiresAt > now + URL_EXPIRY_MARGIN_MS) items.set(name, cached.item);
|
|
515
|
+
else {
|
|
516
|
+
urlCache.delete(cacheKey(scope, name));
|
|
517
|
+
unresolved.push(name);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
let missing = [];
|
|
521
|
+
if (unresolved.length) {
|
|
522
|
+
const resolved = await call("POST", "/files/urls", {
|
|
523
|
+
body: { names: unresolved },
|
|
524
|
+
query: scopeParams2(scope)
|
|
525
|
+
});
|
|
526
|
+
missing = resolved.missing;
|
|
527
|
+
for (const item of resolved.items) {
|
|
528
|
+
const normalized = { ...item, url: new URL(item.url, location.origin).toString() };
|
|
529
|
+
const expiresAt = item.expires_in === null ? Number.POSITIVE_INFINITY : now + item.expires_in * 1e3;
|
|
530
|
+
urlCache.set(cacheKey(scope, item.name), { item: normalized, expiresAt });
|
|
531
|
+
items.set(item.name, normalized);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return { items: unique.flatMap((name) => items.has(name) ? [items.get(name)] : []), missing };
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
function list(scope) {
|
|
538
|
+
return track("files", `${scopeLabel2(scope)}.list()`, async () => {
|
|
539
|
+
const resp = await call("GET", "/files", { query: scopeParams2(scope) });
|
|
540
|
+
return resp.items;
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
function remove(scope, name) {
|
|
544
|
+
return track("files", `${scopeLabel2(scope)}.delete(${q(name)})`, async () => {
|
|
545
|
+
await call("DELETE", `/files/${encPath(name)}`, { query: scopeParams2(scope) });
|
|
546
|
+
urlCache.delete(cacheKey(scope, name));
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
function fileNamespace(scope) {
|
|
550
|
+
return {
|
|
551
|
+
upload: (name, data2, contentType) => upload(scope, name, data2, contentType),
|
|
552
|
+
url: (name) => url(scope, name),
|
|
553
|
+
urls: (names) => urls(scope, names),
|
|
554
|
+
list: () => list(scope),
|
|
555
|
+
delete: (name) => remove(scope, name)
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
var shared2 = fileNamespace({});
|
|
559
|
+
var files = {
|
|
560
|
+
shared: shared2,
|
|
561
|
+
user: fileNamespace({ scope: "user" }),
|
|
562
|
+
role: (roleUuid) => fileNamespace({ scope: "role", role: roleUuid }),
|
|
563
|
+
upload: shared2.upload,
|
|
564
|
+
url: shared2.url,
|
|
565
|
+
urls: shared2.urls,
|
|
566
|
+
list: shared2.list,
|
|
567
|
+
delete: shared2.delete
|
|
568
|
+
};
|
|
463
569
|
|
|
464
570
|
// ../sdk/src/identity.ts
|
|
465
571
|
var me = () => track("me", "me()", () => call("GET", "/me"));
|
|
466
572
|
var appUsers = () => track("app-users", "appUsers()", () => call("GET", "/app-users"));
|
|
573
|
+
var roles = () => track("roles", "roles()", () => call("GET", "/roles"));
|
|
467
574
|
|
|
468
575
|
// ../sdk/src/llm.ts
|
|
469
576
|
function label2(method, input, opts = {}) {
|
|
@@ -589,6 +696,7 @@
|
|
|
589
696
|
target.files = files;
|
|
590
697
|
target.me = me;
|
|
591
698
|
target.appUsers = appUsers;
|
|
699
|
+
target.roles = roles;
|
|
592
700
|
target.designSystem = designSystem;
|
|
593
701
|
target.email = email;
|
|
594
702
|
target.llm = llm;
|
|
@@ -596,7 +704,7 @@
|
|
|
596
704
|
target.data = data;
|
|
597
705
|
target.postgres = postgres;
|
|
598
706
|
target.bigquery = bigquery;
|
|
599
|
-
target.
|
|
707
|
+
target.turso = turso;
|
|
600
708
|
target.dataConnectors = dataConnectors;
|
|
601
709
|
target.connector = connector;
|
|
602
710
|
target.serviceConnectors = serviceConnectors;
|