@terrascale/sdk 0.0.1 → 0.0.3
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/CHANGELOG.md +35 -0
- package/README.md +46 -17
- package/package.json +5 -2
- package/sdk-v1-route-manifest.json +7593 -1175
- package/src/database-codec.js +21 -26
- package/src/database-types.js +11 -11
- package/src/database-view.js +6 -6
- package/src/http.js +26 -5
- package/src/ids.js +22 -0
- package/src/index.js +2 -1
- package/src/local/better-auth.js +17 -6
- package/src/local/index.js +2 -2
- package/src/local/legacy-client.js +5 -5
- package/src/local/router.js +12 -12
- package/src/local/store.js +29 -28
- package/src/local/test-environment.js +3 -2
- package/src/postgres.js +5 -4
- package/src/react/core.js +2 -2
- package/src/ts-auth-discovery.js +17 -0
- package/src/ts-auth-ids.js +122 -0
- package/src/ts-auth.js +75 -72
- package/src/v1/decode.js +30 -10
- package/src/v1/ids.js +10 -62
- package/src/v1/routes.js +5 -5
- package/src/v1/shapes.js +6 -7
- package/src/v1/terrabase-v1.js +24 -16
- package/src/v1/transport.js +6 -4
- package/types/database-codec.d.ts +1 -6
- package/types/database-types.d.ts +11 -11
- package/types/http.d.ts +1 -0
- package/types/ids.d.ts +10 -0
- package/types/index.d.ts +2 -1
- package/types/local/store.d.ts +10 -0
- package/types/local/test-environment.d.ts +2 -2
- package/types/postgres.d.ts +4 -4
- package/types/ts-auth-discovery.d.ts +8 -0
- package/types/ts-auth-ids.d.ts +14 -0
- package/types/ts-auth.d.ts +99 -99
- package/types/v1/decode.d.ts +27 -11
- package/types/v1/ids.d.ts +5 -17
- package/types/v1/shapes.d.ts +2 -2
- package/types/v1/terrabase-v1.d.ts +3 -3
- package/types/v1/transport.d.ts +2 -2
package/src/local/store.js
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdirSync } from "node:fs";
|
|
|
2
2
|
import { DatabaseSync } from "node:sqlite";
|
|
3
3
|
import { dirname, isAbsolute } from "node:path";
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const FORMAT_VERSION = 2;
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* A small synchronous JSON record store for Node local adapters.
|
|
@@ -55,7 +55,7 @@ export class TerraScaleSqliteStore {
|
|
|
55
55
|
if (databasePath !== ":memory:") {
|
|
56
56
|
this.#database.exec("PRAGMA journal_mode = WAL;");
|
|
57
57
|
}
|
|
58
|
-
this.#
|
|
58
|
+
this.#initialize();
|
|
59
59
|
} catch (error) {
|
|
60
60
|
this.#database.close();
|
|
61
61
|
this.#closed = true;
|
|
@@ -128,6 +128,25 @@ export class TerraScaleSqliteStore {
|
|
|
128
128
|
return rows.map((row) => /** @type {T} */ (parseJson(row.value, "record")));
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* @template T
|
|
133
|
+
* @param {string} namespace
|
|
134
|
+
* @param {string} scope
|
|
135
|
+
* @returns {readonly { readonly id: string; readonly value: T }[]}
|
|
136
|
+
*/
|
|
137
|
+
listEntries(namespace, scope) {
|
|
138
|
+
this.#assertOpen();
|
|
139
|
+
assertKey(namespace, "namespace");
|
|
140
|
+
assertKey(scope, "scope");
|
|
141
|
+
const rows = this.#database
|
|
142
|
+
.prepare("SELECT id, value FROM records WHERE namespace = ? AND scope = ? ORDER BY id ASC")
|
|
143
|
+
.all(namespace, scope);
|
|
144
|
+
return rows.map(row => ({
|
|
145
|
+
id: String(row.id),
|
|
146
|
+
value: /** @type {T} */ (parseJson(row.value, "record")),
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
/**
|
|
132
151
|
* @template T
|
|
133
152
|
* @param {string} namespace
|
|
@@ -339,32 +358,18 @@ export class TerraScaleSqliteStore {
|
|
|
339
358
|
}
|
|
340
359
|
|
|
341
360
|
/** @returns {void} */
|
|
342
|
-
#
|
|
361
|
+
#initialize() {
|
|
343
362
|
this.#database.exec("BEGIN IMMEDIATE;");
|
|
344
363
|
try {
|
|
345
|
-
this.#database.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
applied_at TEXT NOT NULL
|
|
349
|
-
) WITHOUT ROWID;
|
|
350
|
-
`);
|
|
351
|
-
|
|
352
|
-
const row = this.#database
|
|
353
|
-
.prepare(
|
|
354
|
-
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1",
|
|
355
|
-
)
|
|
356
|
-
.get();
|
|
357
|
-
const version = row?.version;
|
|
358
|
-
if (version !== undefined && typeof version !== "number") {
|
|
359
|
-
throw new Error("Invalid schema migration version.");
|
|
360
|
-
}
|
|
361
|
-
if (version !== undefined && version > SCHEMA_VERSION) {
|
|
364
|
+
const version = this.#database.prepare("PRAGMA user_version").get()?.user_version;
|
|
365
|
+
const table = this.#database.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
|
|
366
|
+
if (version !== FORMAT_VERSION && !(version === 0 && table === undefined)) {
|
|
362
367
|
throw new Error(
|
|
363
|
-
`
|
|
368
|
+
`TS-754: unsupported local SQLite format. Stop the local runtime, delete ${this.databasePath} and its -wal/-shm files, then restart with fresh ULID storage.`,
|
|
364
369
|
);
|
|
365
370
|
}
|
|
366
371
|
|
|
367
|
-
if (version ===
|
|
372
|
+
if (version === 0) {
|
|
368
373
|
this.#database.exec(`
|
|
369
374
|
CREATE TABLE records (
|
|
370
375
|
namespace TEXT NOT NULL,
|
|
@@ -379,11 +384,7 @@ export class TerraScaleSqliteStore {
|
|
|
379
384
|
value TEXT NOT NULL CHECK (json_valid(value) = 1)
|
|
380
385
|
) WITHOUT ROWID;
|
|
381
386
|
`);
|
|
382
|
-
this.#database
|
|
383
|
-
.prepare(
|
|
384
|
-
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
|
385
|
-
)
|
|
386
|
-
.run(SCHEMA_VERSION, new Date().toISOString());
|
|
387
|
+
this.#database.exec(`PRAGMA user_version = ${FORMAT_VERSION}`);
|
|
387
388
|
}
|
|
388
389
|
this.#database.exec("COMMIT;");
|
|
389
390
|
} catch (error) {
|
|
@@ -406,7 +407,7 @@ export class TerraScaleSqliteStore {
|
|
|
406
407
|
);
|
|
407
408
|
}
|
|
408
409
|
} catch {
|
|
409
|
-
// Preserve the original
|
|
410
|
+
// Preserve the original operation exception. SQLite will normally
|
|
410
411
|
// have already rolled back a failed transaction automatically.
|
|
411
412
|
}
|
|
412
413
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateId } from "../ids.js";
|
|
1
2
|
import { assertLocalSqliteEnvironment } from "./environment.js";
|
|
2
3
|
/** Local development tests never relax CI/deployment/qualification guards. */
|
|
3
4
|
export const localSqliteUnitTestsAllowed = (() => {
|
|
@@ -11,7 +12,7 @@ export const localSqliteUnitTestsAllowed = (() => {
|
|
|
11
12
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
12
13
|
import { join } from "node:path";
|
|
13
14
|
import { tmpdir } from "node:os";
|
|
14
|
-
import { randomBytes
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
15
16
|
import { createTerraScaleSqliteRuntime } from "./index.js";
|
|
16
17
|
/** @import { TerraScaleSqliteRuntimeOptions } from "./index.js" */
|
|
17
18
|
/** @import { DatabaseCommit, DatabaseField, DatabasePoint } from "../database-types.js" */
|
|
@@ -25,7 +26,7 @@ export async function localFixture(options = {}) {
|
|
|
25
26
|
...options,
|
|
26
27
|
sqlite: { enabled: true, databasePath },
|
|
27
28
|
});
|
|
28
|
-
const collection =
|
|
29
|
+
const collection = generateId();
|
|
29
30
|
const binding = {
|
|
30
31
|
collection,
|
|
31
32
|
schema_generation: 1n,
|
package/src/postgres.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValidId } from "./ids.js";
|
|
1
2
|
/** @deprecated The C# TerraBase has no PostgreSQL listener.
|
|
2
3
|
* Node-only PostgreSQL client for the signed INT8/TEXT point SQL listener. */
|
|
3
4
|
import { lookup } from "node:dns/promises";
|
|
@@ -38,8 +39,8 @@ const MAX_INT8 = (1n << 63n) - 1n;
|
|
|
38
39
|
* readonly port: number;
|
|
39
40
|
* readonly tlsServerName: string;
|
|
40
41
|
* readonly caCertificatePem: string;
|
|
41
|
-
* readonly database:
|
|
42
|
-
* readonly keyId:
|
|
42
|
+
* readonly database: import("./ids.js").EntityId;
|
|
43
|
+
* readonly keyId: import("./ids.js").EntityId;
|
|
43
44
|
* readonly password: string;
|
|
44
45
|
* readonly connectTimeoutMs?: number;
|
|
45
46
|
* }} PostgresConfig
|
|
@@ -168,8 +169,8 @@ function configuration(config) {
|
|
|
168
169
|
!Number.isInteger(timeout) ||
|
|
169
170
|
timeout < 1 ||
|
|
170
171
|
timeout > 10000 ||
|
|
171
|
-
|
|
172
|
-
!
|
|
172
|
+
!isValidId(config.database) ||
|
|
173
|
+
!isValidId(config.keyId) ||
|
|
173
174
|
parts.length !== 3 ||
|
|
174
175
|
parts[0] !== "tbk1" ||
|
|
175
176
|
parts[1] !== config.keyId ||
|
package/src/react/core.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateId } from "../ids.js";
|
|
1
2
|
/** @import { TerraScaleError, TerraScaleResult } from "../result.js" */
|
|
2
3
|
/** @import { DatabaseResult } from "../database-types.js" */
|
|
3
4
|
|
|
@@ -733,8 +734,7 @@ function normalizeIdempotencyKey(value) {
|
|
|
733
734
|
|
|
734
735
|
/** @returns {string} */
|
|
735
736
|
function createIdempotencyKey() {
|
|
736
|
-
|
|
737
|
-
return randomUuid === undefined ? `tsm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` : randomUuid.call(globalThis.crypto);
|
|
737
|
+
return generateId();
|
|
738
738
|
}
|
|
739
739
|
|
|
740
740
|
/** @returns {ReactiveMutationError} */
|
package/src/ts-auth-discovery.js
CHANGED
|
@@ -3,6 +3,23 @@ const AUTH_DISCOVERY_URL =
|
|
|
3
3
|
const MAX_ENDPOINTS = 20;
|
|
4
4
|
const MAX_BYTES = 65_536;
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* ts-auth issues from its regional host, https://<region>.auth.terrascale.tech.
|
|
8
|
+
* Behind the Atlas proxy that issuer's region is called directly: Atlas could
|
|
9
|
+
* rank another region, which is another issuer.
|
|
10
|
+
* @param {string} issuer
|
|
11
|
+
* @returns {string | undefined}
|
|
12
|
+
*/
|
|
13
|
+
export function regionalIssuerOrigin(issuer) {
|
|
14
|
+
const region =
|
|
15
|
+
/^https:\/\/([a-z0-9][a-z0-9-]{0,15})\.auth\.terrascale\.tech\//u.exec(
|
|
16
|
+
issuer,
|
|
17
|
+
)?.[1];
|
|
18
|
+
return region === undefined
|
|
19
|
+
? undefined
|
|
20
|
+
: `https://${region}.auth.terrascale.tech`;
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
/** The public auth routing document, validated before any credentials are sent. */
|
|
7
24
|
export class TsAuthEndpointDiscovery {
|
|
8
25
|
/**
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { requireId } from "./ids.js";
|
|
2
|
+
|
|
3
|
+
const singleEntityRoutes = new Set([
|
|
4
|
+
"tsauth.users.get", "tsauth.users.create", "tsauth.users.update",
|
|
5
|
+
"tsauth.apiKeys.create",
|
|
6
|
+
"tsauth.organizations.get", "tsauth.organizations.create", "tsauth.organizations.update",
|
|
7
|
+
"tsauth.organizations.createMembership", "tsauth.organizations.updateMembership",
|
|
8
|
+
"tsauth.organizations.createInvitation", "tsauth.organizations.revokeInvitation", "tsauth.organizations.createDomain",
|
|
9
|
+
"tsauth.connections.create", "tsauth.connections.update",
|
|
10
|
+
"tsauth.directories.create", "tsauth.directories.rotate",
|
|
11
|
+
"tsauth.jwtTemplates.create", "tsauth.jwtTemplates.update",
|
|
12
|
+
"tsauth.webhooks.create",
|
|
13
|
+
"tsauth.instance.get", "tsauth.instance.update",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const requiredRootReferences = new Map([
|
|
17
|
+
["tsauth.oidc.userinfo", "sub"],
|
|
18
|
+
["tsauth.organizations.enrollDomain", "org_id"],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** @type {ReadonlyMap<string, readonly string[]>} */
|
|
22
|
+
const entityReferenceFields = new Map([
|
|
23
|
+
["tsauth.organizations.createMembership", ["organization_id", "user_id"]],
|
|
24
|
+
["tsauth.organizations.updateMembership", ["organization_id", "user_id"]],
|
|
25
|
+
["tsauth.organizations.listMemberships", ["organization_id", "user_id"]],
|
|
26
|
+
["tsauth.organizations.createInvitation", ["organization_id"]],
|
|
27
|
+
["tsauth.organizations.revokeInvitation", ["organization_id"]],
|
|
28
|
+
["tsauth.organizations.listInvitations", ["organization_id"]],
|
|
29
|
+
["tsauth.organizations.createDomain", ["organization_id"]],
|
|
30
|
+
["tsauth.organizations.listDomains", ["organization_id"]],
|
|
31
|
+
["tsauth.auditEvents.list", ["instance_id"]],
|
|
32
|
+
["tsauth.webhooks.deadLetter", ["endpoint_id", "event_id"]],
|
|
33
|
+
["tsauth.oauthClients.create", ["client_id"]],
|
|
34
|
+
["tsauth.oauthClients.update", ["client_id"]],
|
|
35
|
+
["tsauth.oauthClients.list", ["client_id"]],
|
|
36
|
+
["tsauth.oauthResources.create", ["introspection_client_id"]],
|
|
37
|
+
["tsauth.oauthResources.update", ["introspection_client_id"]],
|
|
38
|
+
["tsauth.oauthResources.list", ["introspection_client_id"]],
|
|
39
|
+
["tsauth.connections.create", ["org_id"]],
|
|
40
|
+
["tsauth.connections.update", ["org_id"]],
|
|
41
|
+
["tsauth.connections.list", ["org_id"]],
|
|
42
|
+
["tsauth.directories.create", ["org_id"]],
|
|
43
|
+
["tsauth.directories.rotate", ["org_id"]],
|
|
44
|
+
["tsauth.directories.list", ["org_id"]],
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate the documented endpointId:eventId delivery selector.
|
|
49
|
+
* @param {unknown} value
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function requireDeadLetterId(value) {
|
|
53
|
+
if (typeof value !== "string" || value.split(":").length !== 2)
|
|
54
|
+
throw new TypeError("Dead-letter selector must contain endpoint and event ULIDs separated by a colon.");
|
|
55
|
+
const [endpoint, event] = value.split(":");
|
|
56
|
+
requireId(endpoint, "endpoint id");
|
|
57
|
+
requireId(event, "event id");
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate identity fields in the hosted-auth DTOs without inspecting customer metadata.
|
|
63
|
+
* @param {unknown} value
|
|
64
|
+
* @param {string} route
|
|
65
|
+
* @param {boolean} [entity]
|
|
66
|
+
* @returns {void}
|
|
67
|
+
*/
|
|
68
|
+
export function validateTsAuthIds(value, route, entity = singleEntityRoutes.has(route)) {
|
|
69
|
+
const requiredReference = requiredRootReferences.get(route);
|
|
70
|
+
if ((entity || requiredReference !== undefined) && (typeof value !== "object" || value === null || Array.isArray(value)))
|
|
71
|
+
throw new TypeError("Hosted-auth response must be an object with its required ULID.");
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
if (route === "tsauth.users.regenerateRecoveryCodes") {
|
|
74
|
+
if (!value.every(code => typeof code === "string"))
|
|
75
|
+
throw new TypeError("Recovery codes must be strings.");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of value) validateTsAuthIds(entry, route, true);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (typeof value !== "object" || value === null) return;
|
|
82
|
+
if (requiredReference !== undefined) requireId(Reflect.get(value, requiredReference), requiredReference);
|
|
83
|
+
const deadLetter = route === "tsauth.webhooks.deadLetter";
|
|
84
|
+
if (entity) {
|
|
85
|
+
if (deadLetter) requireDeadLetterId(Reflect.get(value, "id"));
|
|
86
|
+
else requireId(Reflect.get(value, "id"), "entity id");
|
|
87
|
+
}
|
|
88
|
+
const fields = ["id", "user_id", "organization_id", "org_id", "endpoint_id", "event_id", "instance_id", "actor_id"];
|
|
89
|
+
const nullableOrgId = route.startsWith("tsauth.connections.") || route.startsWith("tsauth.directories.");
|
|
90
|
+
if (/^tsauth\.(oauthClients|oauthResources|oidc)\./u.test(route))
|
|
91
|
+
fields.push("client_id", "introspection_client_id", "sub", "sid", "jti");
|
|
92
|
+
const requiredFields = entityReferenceFields.get(route) ?? [];
|
|
93
|
+
const validateReferences = entity || Object.hasOwn(value, "id");
|
|
94
|
+
for (const field of fields) {
|
|
95
|
+
if (Object.hasOwn(value, field) || (validateReferences && requiredFields.includes(field))) {
|
|
96
|
+
const id = Reflect.get(value, field);
|
|
97
|
+
if (field === "id" && deadLetter) requireDeadLetterId(id);
|
|
98
|
+
else if (id !== null || !(field === "sid" || (field === "org_id" && nullableOrgId))) requireId(id, field);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// `data` objects in audit events are customer payloads; only page arrays contain DTOs.
|
|
102
|
+
if (Object.hasOwn(value, "data") && Array.isArray(Reflect.get(value, "data")))
|
|
103
|
+
validateTsAuthIds(Reflect.get(value, "data"), route);
|
|
104
|
+
for (const field of ["passkeys", "clients", "resources"])
|
|
105
|
+
if (Object.hasOwn(value, field)) validateTsAuthIds(Reflect.get(value, field), route);
|
|
106
|
+
for (const field of ["organization", "user"]) {
|
|
107
|
+
if (Object.hasOwn(value, field) && Reflect.get(value, field) !== null) {
|
|
108
|
+
const relatedRoute = field === "organization" ? "tsauth.organizations.get" : "tsauth.users.get";
|
|
109
|
+
validateTsAuthIds(Reflect.get(value, field), relatedRoute, true);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let oauthEntity;
|
|
113
|
+
if (route.startsWith("tsauth.oauthClients.")) oauthEntity = "client";
|
|
114
|
+
else if (route.startsWith("tsauth.oauthResources.")) oauthEntity = "resource";
|
|
115
|
+
if (oauthEntity !== undefined && Object.hasOwn(value, oauthEntity))
|
|
116
|
+
validateTsAuthIds(Reflect.get(value, oauthEntity), route, true);
|
|
117
|
+
if (Object.hasOwn(value, "allowed_client_ids")) {
|
|
118
|
+
const ids = Reflect.get(value, "allowed_client_ids");
|
|
119
|
+
if (!Array.isArray(ids)) throw new TypeError("allowed_client_ids must be an array of ULIDs.");
|
|
120
|
+
for (const id of ids) requireId(id, "allowed_client_ids");
|
|
121
|
+
}
|
|
122
|
+
}
|