anbaric-cloud-hosting 1.42.0 → 1.44.0
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 +7 -0
- package/package.json +7 -7
- package/src/auth/CliKey.ts +7 -1
- package/src/auth/HttpCliKeyStore.ts +5 -2
- package/src/auth/HttpMembershipService.ts +38 -12
- package/src/auth/InMemoryMembershipService.ts +16 -4
- package/src/auth/MembershipService.ts +12 -7
- package/src/auth/SessionSigner.ts +5 -2
- package/src/auth/TenantRole.ts +32 -0
- package/src/auth/TokenAuthenticator.ts +1 -1
- package/src/auth/User.ts +7 -1
- package/src/data-store/Migrations.ts +74 -0
- package/src/data-store/PlatformMigrations.ts +209 -0
- package/src/data-store/Schema.ts +29 -262
- package/src/hosting/HostingServer.ts +1 -1
- package/src/hosting/handlers/AppsHandler.ts +11 -0
- package/src/hosting/handlers/auth/AuthorizeCliHandler.ts +9 -1
- package/src/hosting/handlers/auth/InvitationsHandler.ts +22 -5
- package/src/hosting/handlers/auth/WhoamiHandler.ts +1 -0
- package/src/hosting/middleware/AuthenticationMiddleware.ts +26 -1
- package/src/hosting/pages/platform-ui.html +2 -2
- package/src/index.ts +1 -0
package/README.md
CHANGED
|
@@ -74,3 +74,10 @@ they are persisted.
|
|
|
74
74
|
|
|
75
75
|
Postgres schema management is automatic on boot (`ensureSchema`): workflow
|
|
76
76
|
tables in `public`, platform tables in `anbaric_system`.
|
|
77
|
+
|
|
78
|
+
Schema changes are migrations. `src/data-store/PlatformMigrations.ts` lists them
|
|
79
|
+
in order, each with a stable id; `migrate` records the ids it has applied in
|
|
80
|
+
`anbaric_schema_migrations`, takes an advisory lock so two instances starting
|
|
81
|
+
together take turns, and runs each migration in its own transaction. A migration
|
|
82
|
+
that fails stops the boot. To change the schema, append a numbered entry —
|
|
83
|
+
never edit one that has shipped, and never reuse an id.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
4
4
|
"description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "chris@anbaric.ai",
|
|
@@ -18,18 +18,18 @@
|
|
|
18
18
|
"@aws-sdk/client-s3": "^3.1110.0",
|
|
19
19
|
"@aws-sdk/client-secrets-manager": "^3.700.0",
|
|
20
20
|
"@aws-sdk/client-servicediscovery": "^3.1110.0",
|
|
21
|
-
"anbaric-data-store": "^1.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
24
|
-
"anbaric-web": "^1.
|
|
21
|
+
"anbaric-data-store": "^1.44.0",
|
|
22
|
+
"anbaric-plugins": "^1.44.0",
|
|
23
|
+
"anbaric-tsapi": "^1.44.0",
|
|
24
|
+
"anbaric-web": "^1.44.0",
|
|
25
25
|
"esbuild": "^0.28.2",
|
|
26
26
|
"pg": "^8.16.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^26.2.0",
|
|
30
30
|
"@types/pg": "^8.15.0",
|
|
31
|
-
"anbaric-impl-cloud": "^1.
|
|
32
|
-
"anbaric-state-machine": "^1.
|
|
31
|
+
"anbaric-impl-cloud": "^1.44.0",
|
|
32
|
+
"anbaric-state-machine": "^1.44.0",
|
|
33
33
|
"tsx": "^4.20.0",
|
|
34
34
|
"typescript": "^7.0.2"
|
|
35
35
|
},
|
package/src/auth/CliKey.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {TenantRole} from "./TenantRole";
|
|
2
|
+
|
|
1
3
|
class CliKey {
|
|
2
4
|
|
|
3
5
|
readonly id : string;
|
|
@@ -5,16 +7,20 @@ class CliKey {
|
|
|
5
7
|
readonly clientName : string;
|
|
6
8
|
readonly publicKey : string;
|
|
7
9
|
readonly tenant? : string;
|
|
10
|
+
// The role the key's owner held in that tenant when it was issued; the
|
|
11
|
+
// platform re-reads it on every lookup, so a demotion takes effect at once.
|
|
12
|
+
readonly tenantRole? : TenantRole;
|
|
8
13
|
readonly createdAt : Date;
|
|
9
14
|
|
|
10
15
|
constructor(id : string, userId : string, clientName : string, publicKey : string,
|
|
11
|
-
tenant? : string, createdAt : Date = new Date()) {
|
|
16
|
+
tenant? : string, createdAt : Date = new Date(), tenantRole? : TenantRole) {
|
|
12
17
|
this.id = id;
|
|
13
18
|
this.userId = userId;
|
|
14
19
|
this.clientName = clientName;
|
|
15
20
|
this.publicKey = publicKey;
|
|
16
21
|
this.tenant = tenant;
|
|
17
22
|
this.createdAt = createdAt;
|
|
23
|
+
this.tenantRole = tenantRole;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {CliKey} from "./CliKey";
|
|
2
2
|
import {CliKeyStore} from "./CliKeyStore";
|
|
3
|
+
import {TenantRole} from "./TenantRole";
|
|
3
4
|
|
|
4
5
|
type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
|
|
5
6
|
|
|
@@ -44,8 +45,10 @@ class HttpCliKeyStore implements CliKeyStore {
|
|
|
44
45
|
if (response.status === 404) return undefined;
|
|
45
46
|
if (!response.ok) throw new Error(`The key lookup failed with status ${response.status}`);
|
|
46
47
|
|
|
47
|
-
const found = await response.json() as
|
|
48
|
-
|
|
48
|
+
const found = await response.json() as
|
|
49
|
+
{ userId : string, clientName? : string, publicKey : string, tenant? : string, tenantRole? : TenantRole };
|
|
50
|
+
return new CliKey(id, found.userId, found.clientName ?? "", found.publicKey, found.tenant ?? undefined,
|
|
51
|
+
new Date(), found.tenantRole);
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
}
|
|
@@ -1,40 +1,66 @@
|
|
|
1
1
|
import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
|
|
2
|
+
import {isTenantRole, TenantRole} from "./TenantRole";
|
|
2
3
|
|
|
3
4
|
type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
const ROLE_CACHE_TTL_MS = 60_000;
|
|
7
|
+
|
|
8
|
+
/* Talks to the central login about this one tenant, with the same central api
|
|
9
|
+
key the platform already holds for CLI key lookups. Roles are cached briefly
|
|
10
|
+
- every authenticated request asks for one, and a minute's staleness on a
|
|
11
|
+
role change is a fair trade for not calling central on each of them. */
|
|
7
12
|
class HttpMembershipService implements MembershipService {
|
|
8
13
|
|
|
14
|
+
private roles = new Map<string, { role : TenantRole | undefined, expires : number }>();
|
|
15
|
+
|
|
9
16
|
constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
|
|
10
|
-
private fetchFn : FetchFn = (url, init) => fetch(url, init)
|
|
17
|
+
private fetchFn : FetchFn = (url, init) => fetch(url, init),
|
|
18
|
+
private cacheTtlMs : number = ROLE_CACHE_TTL_MS) {}
|
|
19
|
+
|
|
20
|
+
async roleFor(userId : string) : Promise<TenantRole | undefined> {
|
|
21
|
+
const cached = this.roles.get(userId);
|
|
22
|
+
if (cached && cached.expires > Date.now()) return cached.role;
|
|
23
|
+
|
|
24
|
+
const response = await this.call("GET", `/members/${encodeURIComponent(userId)}`, undefined, [404]);
|
|
25
|
+
const role = response.status === 404
|
|
26
|
+
? undefined
|
|
27
|
+
: (await response.json() as { role? : unknown }).role;
|
|
28
|
+
const found = isTenantRole(role) ? role : undefined;
|
|
29
|
+
|
|
30
|
+
this.roles.set(userId, { role: found, expires: Date.now() + this.cacheTtlMs });
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
11
33
|
|
|
12
|
-
async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
|
|
13
|
-
const response = await this.call("POST", "", { email, invitedBy });
|
|
34
|
+
async invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation> {
|
|
35
|
+
const response = await this.call("POST", "/invitations", { email, role, invitedBy });
|
|
14
36
|
return await response.json() as PendingInvitation;
|
|
15
37
|
}
|
|
16
38
|
|
|
17
|
-
async pending() : Promise<Array<PendingInvitation>> {
|
|
18
|
-
const response = await this.call("GET", "");
|
|
39
|
+
async pending(asking : Inviter) : Promise<Array<PendingInvitation>> {
|
|
40
|
+
const response = await this.call("GET", "/invitations", undefined, [], asking);
|
|
19
41
|
return await response.json() as Array<PendingInvitation>;
|
|
20
42
|
}
|
|
21
43
|
|
|
22
|
-
async revoke(token : string) : Promise<boolean> {
|
|
23
|
-
const response = await this.call("DELETE",
|
|
44
|
+
async revoke(token : string, asking : Inviter) : Promise<boolean> {
|
|
45
|
+
const response = await this.call("DELETE", `/invitations/${encodeURIComponent(token)}`, undefined, [403, 404], asking);
|
|
24
46
|
return response.status === 204;
|
|
25
47
|
}
|
|
26
48
|
|
|
27
|
-
private async call(method : string, path : string, body? : unknown,
|
|
49
|
+
private async call(method : string, path : string, body? : unknown,
|
|
50
|
+
tolerated : Array<number> = [], asking? : Inviter) : Promise<Response> {
|
|
28
51
|
const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
|
|
29
52
|
if (body !== undefined) headers["content-type"] = "application/json";
|
|
53
|
+
// Central checks the acting person's role, not just the api key, so who
|
|
54
|
+
// is asking travels on every call that is not already carrying them.
|
|
55
|
+
if (asking) headers["x-anbaric-user"] = asking.id;
|
|
30
56
|
|
|
31
57
|
const response = await this.fetchFn(
|
|
32
|
-
`${this.centralUrl.replace(/\/$/, "")}/tenants/${encodeURIComponent(this.tenantSlug)}
|
|
58
|
+
`${this.centralUrl.replace(/\/$/, "")}/tenants/${encodeURIComponent(this.tenantSlug)}${path}`,
|
|
33
59
|
{ method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
34
60
|
|
|
35
61
|
if (! response.ok && ! tolerated.includes(response.status)) {
|
|
36
62
|
const problem = await response.json().catch(() => ({})) as { error? : string };
|
|
37
|
-
throw new Error(problem.error ?? `The
|
|
63
|
+
throw new Error(problem.error ?? `The request to the central login failed with status ${response.status}`);
|
|
38
64
|
}
|
|
39
65
|
return response;
|
|
40
66
|
}
|
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import {randomUUID} from "node:crypto";
|
|
2
2
|
import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
|
|
3
|
+
import {canInvite, TenantRole} from "./TenantRole";
|
|
3
4
|
|
|
4
5
|
class InMemoryMembershipService implements MembershipService {
|
|
5
6
|
|
|
6
7
|
private invitations = new Map<string, PendingInvitation>();
|
|
8
|
+
private members = new Map<string, TenantRole>();
|
|
7
9
|
|
|
8
10
|
constructor(private linkBase : string = "http://localhost/invitations") {}
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
add(userId : string, role : TenantRole) : void {
|
|
13
|
+
this.members.set(userId, role);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async roleFor(userId : string) : Promise<TenantRole | undefined> {
|
|
17
|
+
return this.members.get(userId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation> {
|
|
11
21
|
const token = randomUUID();
|
|
12
22
|
const now = new Date();
|
|
13
23
|
const invitation : PendingInvitation = {
|
|
14
24
|
token,
|
|
15
25
|
email: email.trim().toLowerCase(),
|
|
16
|
-
role
|
|
26
|
+
role,
|
|
17
27
|
invitedBy: invitedBy.id,
|
|
18
28
|
invitedByName: invitedBy.name,
|
|
19
29
|
link: `${this.linkBase}/${token}`,
|
|
@@ -25,11 +35,13 @@ class InMemoryMembershipService implements MembershipService {
|
|
|
25
35
|
return { ...invitation };
|
|
26
36
|
}
|
|
27
37
|
|
|
28
|
-
async pending() : Promise<Array<PendingInvitation>> {
|
|
38
|
+
async pending(asking : Inviter) : Promise<Array<PendingInvitation>> {
|
|
39
|
+
if (! canInvite(this.members.get(asking.id))) return [];
|
|
29
40
|
return Array.from(this.invitations.values()).map(invitation => ({ ...invitation })).reverse();
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
async revoke(token : string) : Promise<boolean> {
|
|
43
|
+
async revoke(token : string, asking : Inviter) : Promise<boolean> {
|
|
44
|
+
if (! canInvite(this.members.get(asking.id))) return false;
|
|
33
45
|
return this.invitations.delete(token);
|
|
34
46
|
}
|
|
35
47
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import {TenantRole} from "./TenantRole";
|
|
2
|
+
|
|
1
3
|
type PendingInvitation = {
|
|
2
4
|
|
|
3
5
|
token : string,
|
|
4
6
|
email : string,
|
|
5
|
-
role :
|
|
7
|
+
role : TenantRole,
|
|
6
8
|
invitedBy : string,
|
|
7
9
|
invitedByName? : string,
|
|
8
10
|
link : string,
|
|
@@ -19,16 +21,19 @@ type Inviter = {
|
|
|
19
21
|
|
|
20
22
|
};
|
|
21
23
|
|
|
22
|
-
/*
|
|
23
|
-
sign in (Anbaric Cloud's central
|
|
24
|
-
|
|
24
|
+
/* Who belongs to this tenant and as what, and the invitations that get them
|
|
25
|
+
here. Membership is decided wherever people sign in (Anbaric Cloud's central
|
|
26
|
+
login), which owns the tenant definitions; a platform asks it who its caller
|
|
27
|
+
is, and asks it to invite, list and revoke on the tenant's behalf. */
|
|
25
28
|
interface MembershipService {
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
roleFor(userId : string) : Promise<TenantRole | undefined>;
|
|
31
|
+
|
|
32
|
+
invite(email : string, role : TenantRole, invitedBy : Inviter) : Promise<PendingInvitation>;
|
|
28
33
|
|
|
29
|
-
pending() : Promise<Array<PendingInvitation>>;
|
|
34
|
+
pending(asking : Inviter) : Promise<Array<PendingInvitation>>;
|
|
30
35
|
|
|
31
|
-
revoke(token : string) : Promise<boolean>;
|
|
36
|
+
revoke(token : string, asking : Inviter) : Promise<boolean>;
|
|
32
37
|
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -3,6 +3,7 @@ import {ServerResponse} from "node:http";
|
|
|
3
3
|
import {SESSION_COOKIE} from "./Authenticator";
|
|
4
4
|
import {Role} from "./Role";
|
|
5
5
|
import {Tenant} from "./Tenant";
|
|
6
|
+
import {TenantRole, isTenantRole} from "./TenantRole";
|
|
6
7
|
import {User} from "./User";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
@@ -43,6 +44,7 @@ class SessionSigner {
|
|
|
43
44
|
name: user.name,
|
|
44
45
|
picture: user.picture,
|
|
45
46
|
email: user.email,
|
|
47
|
+
tenantRole: user.tenantRole,
|
|
46
48
|
exp: nowSeconds() + this.ttlSeconds,
|
|
47
49
|
};
|
|
48
50
|
const payload = base64url(JSON.stringify(claims));
|
|
@@ -55,7 +57,7 @@ class SessionSigner {
|
|
|
55
57
|
const [payload, signature] = token.split(".");
|
|
56
58
|
if (!payload || !signature || !this.signatureMatches(payload, signature)) return undefined;
|
|
57
59
|
|
|
58
|
-
let claims : { sub? : unknown, tenant? : unknown, roles? : unknown, name? : unknown, picture? : unknown, email? : unknown, exp? : unknown };
|
|
60
|
+
let claims : { sub? : unknown, tenant? : unknown, roles? : unknown, name? : unknown, picture? : unknown, email? : unknown, tenantRole? : unknown, exp? : unknown };
|
|
59
61
|
try {
|
|
60
62
|
claims = JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
61
63
|
} catch {
|
|
@@ -70,7 +72,8 @@ class SessionSigner {
|
|
|
70
72
|
const name = typeof claims.name === "string" ? claims.name : undefined;
|
|
71
73
|
const picture = typeof claims.picture === "string" ? claims.picture : undefined;
|
|
72
74
|
const email = typeof claims.email === "string" ? claims.email : undefined;
|
|
73
|
-
|
|
75
|
+
const tenantRole = isTenantRole(claims.tenantRole) ? claims.tenantRole as TenantRole : undefined;
|
|
76
|
+
return [new User(claims.sub, roles, [], name, picture, email, tenantRole), tenant];
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
// Sets (or, for a still-valid session, refreshes) the session cookie.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* What a person may do in the tenant this platform serves. The roles are
|
|
2
|
+
assigned in Anbaric Cloud's central login, which owns the tenant definitions
|
|
3
|
+
and the user-to-tenant mapping; the platform only enforces them.
|
|
4
|
+
|
|
5
|
+
Mirrored by that control plane's own tenants/TenantRole.ts rather than
|
|
6
|
+
shared with it, so the two repos need not move in lockstep over a four-value
|
|
7
|
+
union. Keep them in step. */
|
|
8
|
+
type TenantRole = "OWNER" | "ADMIN" | "BUILDER" | "USER";
|
|
9
|
+
|
|
10
|
+
const TENANT_ROLES : Array<TenantRole> = ["OWNER", "ADMIN", "BUILDER", "USER"];
|
|
11
|
+
|
|
12
|
+
const isTenantRole = (value : unknown) : value is TenantRole =>
|
|
13
|
+
typeof value === "string" && TENANT_ROLES.includes(value as TenantRole);
|
|
14
|
+
|
|
15
|
+
/* Inviting and revoking members - administering who else is in the tenant. */
|
|
16
|
+
const canInvite = (role : TenantRole | undefined) : boolean =>
|
|
17
|
+
role === "OWNER" || role === "ADMIN";
|
|
18
|
+
|
|
19
|
+
/* Deploying and tearing down apps, and minting the CLI keypairs that do it.
|
|
20
|
+
A USER can use the tenant's console and its apps, but cannot change them. */
|
|
21
|
+
const canBuild = (role : TenantRole | undefined) : boolean =>
|
|
22
|
+
role === "OWNER" || role === "ADMIN" || role === "BUILDER";
|
|
23
|
+
|
|
24
|
+
/* A platform with no membership service in play - a local or unmanaged
|
|
25
|
+
install - resolves no roles at all, and there every caller is allowed, as
|
|
26
|
+
they were before roles existed. Where one is in play the authentication
|
|
27
|
+
middleware has already refused anyone with no membership, so an undefined
|
|
28
|
+
role at a handler means only that roles are not being enforced here. */
|
|
29
|
+
const deniesBuild = (role : TenantRole | undefined) : boolean => role !== undefined && ! canBuild(role);
|
|
30
|
+
|
|
31
|
+
export { TENANT_ROLES, isTenantRole, canInvite, canBuild, deniesBuild };
|
|
32
|
+
export type { TenantRole };
|
|
@@ -37,7 +37,7 @@ class TokenAuthenticator {
|
|
|
37
37
|
if (this.tenant && key.tenant && key.tenant !== this.tenant) return undefined;
|
|
38
38
|
if (!this.signatureValid(headerPart, payloadPart, signaturePart, key.publicKey)) return undefined;
|
|
39
39
|
|
|
40
|
-
return new User(key.userId);
|
|
40
|
+
return new User(key.userId, [], [], undefined, undefined, undefined, key.tenantRole);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
private signatureValid(headerPart : string, payloadPart : string, signaturePart : string,
|
package/src/auth/User.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {KeyPair} from "./KeyPair";
|
|
2
2
|
import {Role} from "./Role";
|
|
3
|
+
import {TenantRole} from "./TenantRole";
|
|
3
4
|
|
|
4
5
|
class User {
|
|
5
6
|
|
|
@@ -9,15 +10,20 @@ class User {
|
|
|
9
10
|
readonly name? : string;
|
|
10
11
|
readonly picture? : string;
|
|
11
12
|
readonly email? : string;
|
|
13
|
+
// What this person may do in the tenant they are signed in to. Kept apart
|
|
14
|
+
// from `roles`, which are the identity provider's and are what an app's own
|
|
15
|
+
// authorization matches on - a platform capability is not a domain role.
|
|
16
|
+
tenantRole? : TenantRole;
|
|
12
17
|
|
|
13
18
|
constructor(id : string, roles : Array<Role> = [], keyPairs : Array<KeyPair> = [],
|
|
14
|
-
name? : string, picture? : string, email? : string) {
|
|
19
|
+
name? : string, picture? : string, email? : string, tenantRole? : TenantRole) {
|
|
15
20
|
this.id = id;
|
|
16
21
|
this.roles = roles;
|
|
17
22
|
this.keyPairs = keyPairs;
|
|
18
23
|
this.name = name;
|
|
19
24
|
this.picture = picture;
|
|
20
25
|
this.email = email;
|
|
26
|
+
this.tenantRole = tenantRole;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
hasRole(role : Role) : boolean {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {Pool, PoolClient} from "pg";
|
|
2
|
+
|
|
3
|
+
type Migration = {
|
|
4
|
+
|
|
5
|
+
/* A stable identifier, recorded once the migration has run. Never reuse or
|
|
6
|
+
rename one: the record is the only thing standing between a second boot
|
|
7
|
+
and running the same change twice. Numbered prefixes keep the order
|
|
8
|
+
obvious to a reader ("003-tenant-roles"). */
|
|
9
|
+
id : string,
|
|
10
|
+
|
|
11
|
+
run : (client : PoolClient) => Promise<void>,
|
|
12
|
+
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const TABLE = "anbaric_schema_migrations";
|
|
16
|
+
|
|
17
|
+
// One well-known lock, so two instances booting together take it in turns
|
|
18
|
+
// rather than racing to apply the same migration.
|
|
19
|
+
const LOCK_KEY = 8_233_120;
|
|
20
|
+
|
|
21
|
+
/* Applies whatever has not been applied yet, in order, each in its own
|
|
22
|
+
transaction. A migration that throws stops the boot: coming up against a
|
|
23
|
+
half-changed database is worse than not coming up at all. */
|
|
24
|
+
const migrate = async (pool : Pool, migrations : Array<Migration>) : Promise<Array<string>> => {
|
|
25
|
+
const duplicate = migrations.find((migration, at) => migrations.findIndex(other => other.id === migration.id) !== at);
|
|
26
|
+
if (duplicate) throw new Error(`Duplicate migration id "${duplicate.id}"`);
|
|
27
|
+
|
|
28
|
+
const client = await pool.connect();
|
|
29
|
+
const applied : Array<string> = [];
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await client.query(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
36
|
+
)
|
|
37
|
+
`);
|
|
38
|
+
await client.query("SELECT pg_advisory_lock($1)", [LOCK_KEY]);
|
|
39
|
+
|
|
40
|
+
const done = new Set((await client.query(`SELECT id FROM ${TABLE}`)).rows.map(row => row.id));
|
|
41
|
+
|
|
42
|
+
for (const migration of migrations) {
|
|
43
|
+
if (done.has(migration.id)) continue;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await client.query("BEGIN");
|
|
47
|
+
await migration.run(client);
|
|
48
|
+
await client.query(`INSERT INTO ${TABLE} (id) VALUES ($1)`, [migration.id]);
|
|
49
|
+
await client.query("COMMIT");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
52
|
+
throw new Error(`Migration "${migration.id}" failed: ${error instanceof Error ? error.message : error}`);
|
|
53
|
+
}
|
|
54
|
+
applied.push(migration.id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return applied;
|
|
58
|
+
} finally {
|
|
59
|
+
await client.query("SELECT pg_advisory_unlock($1)", [LOCK_KEY]).catch(() => {});
|
|
60
|
+
client.release();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/* The common case: a migration that is just SQL. Several statements may be
|
|
65
|
+
given, and they run in the one transaction the migration already has. */
|
|
66
|
+
const statements = (id : string, ...sql : Array<string>) : Migration => ({
|
|
67
|
+
id,
|
|
68
|
+
run: async (client) => {
|
|
69
|
+
for (const statement of sql) await client.query(statement);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export { migrate, statements };
|
|
74
|
+
export type { Migration };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {Migration, statements} from "./Migrations";
|
|
2
|
+
|
|
3
|
+
/* Workflow data (jobs, queue, documents) lives in the default public schema;
|
|
4
|
+
platform system tables are encapsulated in the anbaric_system schema so the
|
|
5
|
+
two never mix.
|
|
6
|
+
|
|
7
|
+
The baseline is everything the schema had grown to before migrations were
|
|
8
|
+
recorded, so it is written defensively (IF NOT EXISTS, guarded DO blocks) and
|
|
9
|
+
is safe against a database that already has all of it. Everything after it is
|
|
10
|
+
a numbered step that runs exactly once: write new changes as plain SQL and
|
|
11
|
+
never edit a migration that has shipped. */
|
|
12
|
+
const PLATFORM_MIGRATIONS : Array<Migration> = [
|
|
13
|
+
|
|
14
|
+
statements("001-baseline",
|
|
15
|
+
`CREATE TABLE IF NOT EXISTS jobs (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
state TEXT NOT NULL,
|
|
18
|
+
properties JSONB NOT NULL DEFAULT '{}',
|
|
19
|
+
workflow_id TEXT,
|
|
20
|
+
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
21
|
+
started_by TEXT NOT NULL DEFAULT 'system',
|
|
22
|
+
last_updated TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
23
|
+
transitions JSONB NOT NULL DEFAULT '[]',
|
|
24
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
25
|
+
inserted_at BIGINT GENERATED ALWAYS AS IDENTITY
|
|
26
|
+
)`,
|
|
27
|
+
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS workflow_id TEXT",
|
|
28
|
+
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
|
29
|
+
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS started_by TEXT NOT NULL DEFAULT 'system'",
|
|
30
|
+
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_updated TIMESTAMPTZ NOT NULL DEFAULT now()",
|
|
31
|
+
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS transitions JSONB NOT NULL DEFAULT '[]'",
|
|
32
|
+
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS killed BOOLEAN NOT NULL DEFAULT false",
|
|
33
|
+
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'",
|
|
34
|
+
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS app_id TEXT",
|
|
35
|
+
`UPDATE jobs
|
|
36
|
+
SET app_id = split_part(workflow_id, '/', 1),
|
|
37
|
+
workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
|
|
38
|
+
WHERE app_id IS NULL AND workflow_id LIKE '%/%'`,
|
|
39
|
+
`CREATE TABLE IF NOT EXISTS awaits (
|
|
40
|
+
id UUID PRIMARY KEY,
|
|
41
|
+
metadata JSONB NOT NULL DEFAULT '{}',
|
|
42
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
43
|
+
)`,
|
|
44
|
+
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS waiting_for UUID REFERENCES awaits(id) ON DELETE SET NULL",
|
|
45
|
+
`CREATE TABLE IF NOT EXISTS documents (
|
|
46
|
+
app_id TEXT NOT NULL DEFAULT '',
|
|
47
|
+
collection TEXT NOT NULL,
|
|
48
|
+
id TEXT NOT NULL,
|
|
49
|
+
document JSONB NOT NULL,
|
|
50
|
+
inserted_at BIGINT GENERATED ALWAYS AS IDENTITY,
|
|
51
|
+
PRIMARY KEY (app_id, collection, id)
|
|
52
|
+
)`,
|
|
53
|
+
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS app_id TEXT NOT NULL DEFAULT ''",
|
|
54
|
+
`DO $$ BEGIN
|
|
55
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.key_column_usage
|
|
56
|
+
WHERE table_name = 'documents' AND constraint_name = 'documents_pkey'
|
|
57
|
+
AND column_name = 'app_id') THEN
|
|
58
|
+
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_pkey;
|
|
59
|
+
ALTER TABLE documents ADD PRIMARY KEY (app_id, collection, id);
|
|
60
|
+
END IF;
|
|
61
|
+
END $$`,
|
|
62
|
+
`CREATE TABLE IF NOT EXISTS queue (
|
|
63
|
+
position BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
64
|
+
job_id TEXT NOT NULL,
|
|
65
|
+
workflow_id TEXT NOT NULL,
|
|
66
|
+
due TIMESTAMPTZ,
|
|
67
|
+
leased_until TIMESTAMPTZ
|
|
68
|
+
)`,
|
|
69
|
+
"ALTER TABLE queue ADD COLUMN IF NOT EXISTS app_id TEXT",
|
|
70
|
+
"ALTER TABLE queue ADD COLUMN IF NOT EXISTS retry_at TIMESTAMPTZ",
|
|
71
|
+
"ALTER TABLE queue ADD COLUMN IF NOT EXISTS attempts INT NOT NULL DEFAULT 0",
|
|
72
|
+
`UPDATE queue
|
|
73
|
+
SET app_id = split_part(workflow_id, '/', 1),
|
|
74
|
+
workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
|
|
75
|
+
WHERE app_id IS NULL AND workflow_id LIKE '%/%'`,
|
|
76
|
+
`CREATE TABLE IF NOT EXISTS job_run_schedule (
|
|
77
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
78
|
+
app_id TEXT NOT NULL DEFAULT '',
|
|
79
|
+
workflow_id TEXT NOT NULL,
|
|
80
|
+
run_at TIMESTAMPTZ NOT NULL,
|
|
81
|
+
claimed_at TIMESTAMPTZ
|
|
82
|
+
)`,
|
|
83
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS job_run_schedule_run
|
|
84
|
+
ON job_run_schedule (app_id, workflow_id, run_at)`,
|
|
85
|
+
`CREATE INDEX IF NOT EXISTS job_run_schedule_due
|
|
86
|
+
ON job_run_schedule (run_at) WHERE claimed_at IS NULL`,
|
|
87
|
+
`CREATE TABLE IF NOT EXISTS app_docs (
|
|
88
|
+
app_id TEXT NOT NULL,
|
|
89
|
+
slug TEXT NOT NULL,
|
|
90
|
+
title TEXT NOT NULL DEFAULT '',
|
|
91
|
+
markdown TEXT NOT NULL,
|
|
92
|
+
parent_slug TEXT,
|
|
93
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
94
|
+
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
95
|
+
PRIMARY KEY (app_id, slug)
|
|
96
|
+
)`,
|
|
97
|
+
"ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS parent_slug TEXT",
|
|
98
|
+
"ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0",
|
|
99
|
+
`CREATE TABLE IF NOT EXISTS entitlements (
|
|
100
|
+
app_id TEXT,
|
|
101
|
+
entitlement_id TEXT NOT NULL,
|
|
102
|
+
notes TEXT NOT NULL DEFAULT '',
|
|
103
|
+
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
104
|
+
)`,
|
|
105
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS entitlements_key
|
|
106
|
+
ON entitlements (COALESCE(app_id, ''), entitlement_id)`,
|
|
107
|
+
`CREATE TABLE IF NOT EXISTS entitlement_grants (
|
|
108
|
+
id TEXT PRIMARY KEY,
|
|
109
|
+
user_id TEXT NOT NULL,
|
|
110
|
+
app_id TEXT,
|
|
111
|
+
entitlement_id TEXT NOT NULL,
|
|
112
|
+
notes TEXT NOT NULL DEFAULT '',
|
|
113
|
+
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
114
|
+
granted_by TEXT NOT NULL
|
|
115
|
+
)`,
|
|
116
|
+
`CREATE INDEX IF NOT EXISTS entitlement_grants_lookup
|
|
117
|
+
ON entitlement_grants (user_id, entitlement_id)`,
|
|
118
|
+
`INSERT INTO entitlements (app_id, entitlement_id, notes)
|
|
119
|
+
VALUES (NULL, 'access', 'Global access')
|
|
120
|
+
ON CONFLICT (COALESCE(app_id, ''), entitlement_id) DO NOTHING`,
|
|
121
|
+
`CREATE TABLE IF NOT EXISTS prompts (
|
|
122
|
+
app_id TEXT NOT NULL,
|
|
123
|
+
prompt_id TEXT NOT NULL,
|
|
124
|
+
version INTEGER NOT NULL,
|
|
125
|
+
instructions TEXT NOT NULL,
|
|
126
|
+
output_schema JSONB,
|
|
127
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
128
|
+
PRIMARY KEY (app_id, prompt_id, version)
|
|
129
|
+
)`,
|
|
130
|
+
"ALTER TABLE prompts DROP COLUMN IF EXISTS input_schema",
|
|
131
|
+
"CREATE SCHEMA IF NOT EXISTS anbaric_system",
|
|
132
|
+
`CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
|
|
133
|
+
id TEXT PRIMARY KEY,
|
|
134
|
+
user_id TEXT NOT NULL,
|
|
135
|
+
client_name TEXT NOT NULL,
|
|
136
|
+
public_key TEXT NOT NULL,
|
|
137
|
+
tenant TEXT,
|
|
138
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
139
|
+
)`,
|
|
140
|
+
"ALTER TABLE anbaric_system.cli_keys ADD COLUMN IF NOT EXISTS tenant TEXT",
|
|
141
|
+
`CREATE TABLE IF NOT EXISTS anbaric_system.users (
|
|
142
|
+
id TEXT PRIMARY KEY,
|
|
143
|
+
name TEXT NOT NULL DEFAULT '',
|
|
144
|
+
email TEXT NOT NULL DEFAULT '',
|
|
145
|
+
picture TEXT,
|
|
146
|
+
first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
147
|
+
last_seen TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
148
|
+
)`,
|
|
149
|
+
`DO $$ BEGIN
|
|
150
|
+
CREATE TYPE anbaric_system.audit_interaction AS ENUM
|
|
151
|
+
('CREATE', 'UPDATE_PROPERTIES', 'CHANGE_STATE', 'DELETE', 'READ', 'LIST');
|
|
152
|
+
EXCEPTION WHEN duplicate_object THEN null;
|
|
153
|
+
END $$`,
|
|
154
|
+
`CREATE TABLE IF NOT EXISTS anbaric_system.audit_records (
|
|
155
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
156
|
+
resource_type TEXT NOT NULL,
|
|
157
|
+
resource_id TEXT NOT NULL,
|
|
158
|
+
actor_id TEXT NOT NULL,
|
|
159
|
+
actor_type TEXT NOT NULL,
|
|
160
|
+
interaction TEXT[] NOT NULL,
|
|
161
|
+
description TEXT NOT NULL,
|
|
162
|
+
details JSONB,
|
|
163
|
+
at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
164
|
+
)`,
|
|
165
|
+
"ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_type TEXT",
|
|
166
|
+
"ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_id TEXT",
|
|
167
|
+
"ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS app_id TEXT",
|
|
168
|
+
"ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS interaction TEXT[]",
|
|
169
|
+
`DO $$ BEGIN
|
|
170
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
171
|
+
WHERE table_schema = 'anbaric_system' AND table_name = 'audit_records'
|
|
172
|
+
AND column_name = 'interaction' AND data_type <> 'ARRAY') THEN
|
|
173
|
+
ALTER TABLE anbaric_system.audit_records
|
|
174
|
+
ALTER COLUMN interaction TYPE anbaric_system.audit_interaction[] USING ARRAY[interaction];
|
|
175
|
+
END IF;
|
|
176
|
+
END $$`,
|
|
177
|
+
`DO $$ BEGIN
|
|
178
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
179
|
+
WHERE table_schema = 'anbaric_system' AND table_name = 'audit_records'
|
|
180
|
+
AND column_name = 'job_id') THEN
|
|
181
|
+
UPDATE anbaric_system.audit_records
|
|
182
|
+
SET resource_type = COALESCE(resource_type, 'job'),
|
|
183
|
+
resource_id = COALESCE(resource_id, job_id),
|
|
184
|
+
interaction = COALESCE(interaction, ARRAY[change::text::anbaric_system.audit_interaction])
|
|
185
|
+
WHERE resource_id IS NULL OR interaction IS NULL;
|
|
186
|
+
ALTER TABLE anbaric_system.audit_records DROP COLUMN job_id;
|
|
187
|
+
ALTER TABLE anbaric_system.audit_records DROP COLUMN change;
|
|
188
|
+
END IF;
|
|
189
|
+
END $$`,
|
|
190
|
+
`DO $$ BEGIN
|
|
191
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
192
|
+
WHERE table_schema = 'anbaric_system' AND table_name = 'audit_records'
|
|
193
|
+
AND column_name = 'interaction' AND udt_name = '_audit_interaction') THEN
|
|
194
|
+
ALTER TABLE anbaric_system.audit_records
|
|
195
|
+
ALTER COLUMN interaction TYPE TEXT[] USING interaction::text[];
|
|
196
|
+
END IF;
|
|
197
|
+
END $$`,
|
|
198
|
+
"UPDATE anbaric_system.audit_records SET resource_type = 'job' WHERE resource_type IS NULL",
|
|
199
|
+
"UPDATE anbaric_system.audit_records SET resource_id = 'unknown' WHERE resource_id IS NULL",
|
|
200
|
+
"UPDATE anbaric_system.audit_records SET interaction = ARRAY['UPDATE_PROPERTIES']::text[] WHERE interaction IS NULL",
|
|
201
|
+
"ALTER TABLE anbaric_system.audit_records ALTER COLUMN resource_type SET NOT NULL",
|
|
202
|
+
"ALTER TABLE anbaric_system.audit_records ALTER COLUMN resource_id SET NOT NULL",
|
|
203
|
+
"ALTER TABLE anbaric_system.audit_records ALTER COLUMN interaction SET NOT NULL",
|
|
204
|
+
"CREATE INDEX IF NOT EXISTS audit_records_resource ON anbaric_system.audit_records (resource_type, resource_id)",
|
|
205
|
+
),
|
|
206
|
+
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
export { PLATFORM_MIGRATIONS };
|