@wtfalch/foundry 0.5.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/bootstrap.d.ts +16 -1
- package/dist/bootstrap/bootstrap.js +40 -2
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +12 -3
- package/dist/bootstrap/versions.d.ts +17 -0
- package/dist/bootstrap/versions.js +12 -0
- package/dist/calls-sfu-deploy-support.d.ts +8 -0
- package/dist/calls-sfu-deploy-support.js +10 -1
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +42 -9
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -3
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/org.d.ts +105 -0
- package/dist/org.js +101 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +2 -2
- package/dist/plan/calls-matrix.js +3 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +15 -2
- package/dist/plan/diff.js +399 -51
- package/dist/plan/executor.d.ts +307 -61
- package/dist/plan/executor.js +489 -129
- package/dist/plan/index.d.ts +9 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/login-host.d.ts +64 -0
- package/dist/plan/login-host.js +52 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +26 -8
- package/dist/plan/plan-apply.js +10 -9
- package/dist/plan/platform-diff.d.ts +207 -10
- package/dist/plan/platform-diff.js +403 -15
- package/dist/plan/platform-org.d.ts +3 -2
- package/dist/plan/platform-providers.d.ts +139 -25
- package/dist/plan/platform-providers.js +1531 -95
- package/dist/plan/platform-sfu.d.ts +54 -19
- package/dist/plan/platform-sfu.js +64 -22
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan/zitadel-machine-user.d.ts +60 -11
- package/dist/plan/zitadel-machine-user.js +182 -39
- package/dist/requested-changes.d.ts +17 -7
- package/dist/requested-changes.js +37 -7
- package/dist/store/audit.d.ts +40 -4
- package/dist/store/audit.js +114 -29
- package/dist/store/postgres.js +35 -3
- package/dist/store/schema.d.ts +109 -1
- package/dist/store/schema.js +20 -0
- package/dist/store.d.ts +7 -0
- package/package.json +5 -3
- package/versions.json +8 -0
|
@@ -11,7 +11,9 @@ import type { PlanDetail } from './plan/diff.js';
|
|
|
11
11
|
* and safe; `domain` and `mailTier` are full self-service too, but both are
|
|
12
12
|
* migrating steps (`plan/diff.ts`'s `planMail` moves live mail between
|
|
13
13
|
* modes; a domain move is at least as disruptive) and always need approval,
|
|
14
|
-
* never auto.
|
|
14
|
+
* never auto. #184 narrows `storageLimit`/`aiLimit`'s "small and safe" to
|
|
15
|
+
* lowering or leaving a limit unchanged -- raising either one needs
|
|
16
|
+
* approval too, until a billing field exists to say what the raise costs.
|
|
15
17
|
*/
|
|
16
18
|
export type RequestableField = 'calls' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
|
|
17
19
|
export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
|
|
@@ -85,12 +87,20 @@ export declare class RequestedChangeError extends Error {
|
|
|
85
87
|
constructor(message: string);
|
|
86
88
|
}
|
|
87
89
|
/**
|
|
88
|
-
* `auto` when `change` is small and safe (`calls`, `storageLimit
|
|
89
|
-
* `aiLimit`), the plan it causes has
|
|
90
|
-
* it causes no price change on
|
|
91
|
-
* otherwise -- every `domain` or
|
|
92
|
-
* unconditionally, since #178
|
|
93
|
-
* always need approval, whatever
|
|
90
|
+
* `auto` when `change` is small and safe (`calls`, and `storageLimit`/
|
|
91
|
+
* `aiLimit` in the lowering or unchanged direction), the plan it causes has
|
|
92
|
+
* no destructive or migrating step, and it causes no price change on
|
|
93
|
+
* `org`'s own billing fields; `approval` otherwise -- every `domain` or
|
|
94
|
+
* `mailTier` change classifies `approval` unconditionally, since #178
|
|
95
|
+
* "Decide" calls both migrating steps that always need approval, whatever
|
|
96
|
+
* direction the change moves.
|
|
97
|
+
*
|
|
98
|
+
* A `storageLimit` or `aiLimit` change that *raises* either limit
|
|
99
|
+
* ({@link isLimitRaise} against `org`'s current value) classifies
|
|
100
|
+
* `approval`, decided in #184: "a raise of the storage or AI limit
|
|
101
|
+
* classifies `approval` until billing fields exist. A lowering stays
|
|
102
|
+
* `auto`." `storageLimit` carries two independent limits (`limitBytes`,
|
|
103
|
+
* `limitMicros`); a raise on either one is enough.
|
|
94
104
|
*
|
|
95
105
|
* `plan` is the {@link PlanDetail} `change` causes -- the caller's own diff
|
|
96
106
|
* against live reality, from the same `computePlanDetail` (`plan/diff.ts`)
|
|
@@ -53,12 +53,37 @@ function priceChanges(_before, _after) {
|
|
|
53
53
|
return false;
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
* `
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
56
|
+
* Whether a `StorageConfig`/`AiConfig` limit field moved up from `before` to
|
|
57
|
+
* `after` -- both are `null`-or-bigint-string, `null` meaning uncapped
|
|
58
|
+
* (`org.ts`'s `StorageConfig`/`AiConfig` doc). `null` is the largest
|
|
59
|
+
* possible limit, so moving a capped `before` to an uncapped `after` is a
|
|
60
|
+
* raise; moving an already-uncapped `before` anywhere is never a raise
|
|
61
|
+
* (there is nothing higher to move to); two finite values compare as
|
|
62
|
+
* bigints, never as numbers, the same bigint-safety `org.ts` requires of
|
|
63
|
+
* these fields. Equal values are not a raise.
|
|
64
|
+
*/
|
|
65
|
+
function isLimitRaise(before, after) {
|
|
66
|
+
if (before === null)
|
|
67
|
+
return false;
|
|
68
|
+
if (after === null)
|
|
69
|
+
return true;
|
|
70
|
+
return BigInt(after) > BigInt(before);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `auto` when `change` is small and safe (`calls`, and `storageLimit`/
|
|
74
|
+
* `aiLimit` in the lowering or unchanged direction), the plan it causes has
|
|
75
|
+
* no destructive or migrating step, and it causes no price change on
|
|
76
|
+
* `org`'s own billing fields; `approval` otherwise -- every `domain` or
|
|
77
|
+
* `mailTier` change classifies `approval` unconditionally, since #178
|
|
78
|
+
* "Decide" calls both migrating steps that always need approval, whatever
|
|
79
|
+
* direction the change moves.
|
|
80
|
+
*
|
|
81
|
+
* A `storageLimit` or `aiLimit` change that *raises* either limit
|
|
82
|
+
* ({@link isLimitRaise} against `org`'s current value) classifies
|
|
83
|
+
* `approval`, decided in #184: "a raise of the storage or AI limit
|
|
84
|
+
* classifies `approval` until billing fields exist. A lowering stays
|
|
85
|
+
* `auto`." `storageLimit` carries two independent limits (`limitBytes`,
|
|
86
|
+
* `limitMicros`); a raise on either one is enough.
|
|
62
87
|
*
|
|
63
88
|
* `plan` is the {@link PlanDetail} `change` causes -- the caller's own diff
|
|
64
89
|
* against live reality, from the same `computePlanDetail` (`plan/diff.ts`)
|
|
@@ -85,9 +110,14 @@ export function classifyChange(org, change, plan) {
|
|
|
85
110
|
return 'approval';
|
|
86
111
|
switch (change.field) {
|
|
87
112
|
case 'calls':
|
|
113
|
+
return 'auto';
|
|
88
114
|
case 'storageLimit':
|
|
115
|
+
return isLimitRaise(org.storage.limitBytes, change.limitBytes) ||
|
|
116
|
+
isLimitRaise(org.storage.limitMicros, change.limitMicros)
|
|
117
|
+
? 'approval'
|
|
118
|
+
: 'auto';
|
|
89
119
|
case 'aiLimit':
|
|
90
|
-
return 'auto';
|
|
120
|
+
return isLimitRaise(org.ai.limitMicros, change.limitMicros) ? 'approval' : 'auto';
|
|
91
121
|
case 'domain':
|
|
92
122
|
case 'mailTier':
|
|
93
123
|
return 'approval';
|
package/dist/store/audit.d.ts
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
import type { AuditLog } from '../plan/plan-apply.js';
|
|
2
2
|
import type { FoundryStoreDb } from './postgres.js';
|
|
3
3
|
/**
|
|
4
|
-
* foundry's own {@link AuditLog},
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* foundry's own {@link AuditLog}, backed by `@wtfalch/audit`'s ledger
|
|
5
|
+
* (#232) instead of the hand-rolled `foundry_audit_events` table
|
|
6
|
+
* (`migrations/0012_audit.sql`) this replaces. Every row: `tenantId: null`
|
|
7
|
+
* (foundry has no tenant of its own -- the org a plan is *for* is the
|
|
8
|
+
* `target`, not a tenant), `hashChain` on so a changed or deleted row is
|
|
9
|
+
* detectable (`verifyChain`), through migrations 0024-0028
|
|
10
|
+
* (`src/migrations/`, copied from `@wtfalch/audit` 0.6.0's own
|
|
11
|
+
* `migrations/0001-0005`, the same files its own `audit-migrations` CLI
|
|
12
|
+
* would copy into a host that has a `drizzle/` journal -- foundry does not,
|
|
13
|
+
* so they are hand-placed at the next free numbers instead).
|
|
14
|
+
*
|
|
15
|
+
* The `AuditLog` interface this satisfies is unchanged (`plan-apply.ts`),
|
|
16
|
+
* so `bootstrap.ts` and `redactingAudit` need no change either.
|
|
17
|
+
*
|
|
18
|
+
* Decide (#232, open): whether `foundry_audit_events`' existing rows are
|
|
19
|
+
* copied into the ledger or left as a closed, separate trail. Left as-is
|
|
20
|
+
* here -- the simpler default, and the one that forecloses nothing: a
|
|
21
|
+
* backfill can still be written later, where discarding the old rows
|
|
22
|
+
* outright could not be undone.
|
|
23
|
+
*
|
|
24
|
+
* `checkRuntimeRole` defaults to `false` here, the opposite of
|
|
25
|
+
* `createLedger`'s own default (#232, needs a human call -- flagged
|
|
26
|
+
* `ai:needs-human`, not decided here): that check requires the connection
|
|
27
|
+
* to be a real, restricted `<database>_rt` role, the one 0001's migration
|
|
28
|
+
* revokes UPDATE/DELETE/TRUNCATE from by name
|
|
29
|
+
* (`current_database() || '_rt'`). foundry has no such role for its own
|
|
30
|
+
* database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
|
|
31
|
+
* split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
|
|
32
|
+
* `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
|
|
33
|
+
* database -- this is foundry's *own*). Leaving the default on would make
|
|
34
|
+
* every real call throw `UnsafeRuntimeRoleError`, since the revoke in
|
|
35
|
+
* 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
|
|
36
|
+
* actually is; the append-only guard and the chain still hold at the
|
|
37
|
+
* database level regardless (0001's triggers, 0004's hashing), only the
|
|
38
|
+
* extra fail-closed check on the *connection* is off. Options, not a second
|
|
39
|
+
* positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
|
|
40
|
+
* fixture -- can pass `true` once foundry's own database gets that split.
|
|
7
41
|
*/
|
|
8
|
-
export declare function createPostgresAuditLog(db: FoundryStoreDb
|
|
42
|
+
export declare function createPostgresAuditLog(db: FoundryStoreDb, options?: {
|
|
43
|
+
checkRuntimeRole?: boolean;
|
|
44
|
+
}): AuditLog;
|
package/dist/store/audit.js
CHANGED
|
@@ -1,44 +1,129 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createLedger, ledgerVocabulary } from '@wtfalch/audit';
|
|
2
2
|
/**
|
|
3
|
-
* foundry's own
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
3
|
+
* foundry's own closed vocabulary (#232): one event per `AuditLog` method,
|
|
4
|
+
* `outcome` carrying how it went rather than the action name repeating it
|
|
5
|
+
* (the same split `@wtfalch/audit`'s own "service with no tenant database"
|
|
6
|
+
* example uses: `action: 'plan.applied'`, `outcome: 'refused'`). `actor`
|
|
7
|
+
* has one class, `'operator'`, because `AuditActor` (`plan-apply.ts`) does
|
|
8
|
+
* not distinguish a human from a service caller -- nothing here guesses
|
|
9
|
+
* one. `'recorded'` sorts first so it is the default outcome
|
|
10
|
+
* `plan.computed` rows get (`recordPlan` has no outcome of its own to pass).
|
|
6
11
|
*/
|
|
7
|
-
|
|
12
|
+
const FOUNDRY_LEDGER_VOCABULARY = ledgerVocabulary({
|
|
13
|
+
events: {
|
|
14
|
+
'plan.computed': { tenantVisible: false },
|
|
15
|
+
'apply.recorded': { tenantVisible: false },
|
|
16
|
+
'destroy.recorded': { tenantVisible: false },
|
|
17
|
+
},
|
|
18
|
+
actorClasses: ['operator'],
|
|
19
|
+
contexts: ['operator'],
|
|
20
|
+
outcomes: ['recorded', 'applied', 'destroyed', 'refused', 'failed'],
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* The ledger table's CHECKs (migration 0024): reason <= 512 chars, `after`
|
|
24
|
+
* <= 65536 bytes as jsonb text, actor_display 1-256 chars. A write that
|
|
25
|
+
* breaks one throws and would hide the apply error being recorded, so each
|
|
26
|
+
* value is bounded here. Postgres' jsonb text adds a space after every `:`
|
|
27
|
+
* and `,`, so the plan is measured against half the limit.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_REASON = 512;
|
|
30
|
+
const MAX_AFTER_BYTES = 32768;
|
|
31
|
+
const MAX_DISPLAY = 256;
|
|
32
|
+
function boundReason(reason) {
|
|
33
|
+
return reason === undefined ? null : reason.slice(0, MAX_REASON);
|
|
34
|
+
}
|
|
35
|
+
function boundDisplay(actor) {
|
|
36
|
+
return (actor.display || actor.id).slice(0, MAX_DISPLAY);
|
|
37
|
+
}
|
|
38
|
+
function boundPlan(plan) {
|
|
39
|
+
return Buffer.byteLength(JSON.stringify(plan)) <= MAX_AFTER_BYTES
|
|
40
|
+
? plan
|
|
41
|
+
: { truncated: true, steps: plan.steps.length, unmanaged: plan.unmanaged.length };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* foundry's own {@link AuditLog}, backed by `@wtfalch/audit`'s ledger
|
|
45
|
+
* (#232) instead of the hand-rolled `foundry_audit_events` table
|
|
46
|
+
* (`migrations/0012_audit.sql`) this replaces. Every row: `tenantId: null`
|
|
47
|
+
* (foundry has no tenant of its own -- the org a plan is *for* is the
|
|
48
|
+
* `target`, not a tenant), `hashChain` on so a changed or deleted row is
|
|
49
|
+
* detectable (`verifyChain`), through migrations 0024-0028
|
|
50
|
+
* (`src/migrations/`, copied from `@wtfalch/audit` 0.6.0's own
|
|
51
|
+
* `migrations/0001-0005`, the same files its own `audit-migrations` CLI
|
|
52
|
+
* would copy into a host that has a `drizzle/` journal -- foundry does not,
|
|
53
|
+
* so they are hand-placed at the next free numbers instead).
|
|
54
|
+
*
|
|
55
|
+
* The `AuditLog` interface this satisfies is unchanged (`plan-apply.ts`),
|
|
56
|
+
* so `bootstrap.ts` and `redactingAudit` need no change either.
|
|
57
|
+
*
|
|
58
|
+
* Decide (#232, open): whether `foundry_audit_events`' existing rows are
|
|
59
|
+
* copied into the ledger or left as a closed, separate trail. Left as-is
|
|
60
|
+
* here -- the simpler default, and the one that forecloses nothing: a
|
|
61
|
+
* backfill can still be written later, where discarding the old rows
|
|
62
|
+
* outright could not be undone.
|
|
63
|
+
*
|
|
64
|
+
* `checkRuntimeRole` defaults to `false` here, the opposite of
|
|
65
|
+
* `createLedger`'s own default (#232, needs a human call -- flagged
|
|
66
|
+
* `ai:needs-human`, not decided here): that check requires the connection
|
|
67
|
+
* to be a real, restricted `<database>_rt` role, the one 0001's migration
|
|
68
|
+
* revokes UPDATE/DELETE/TRUNCATE from by name
|
|
69
|
+
* (`current_database() || '_rt'`). foundry has no such role for its own
|
|
70
|
+
* database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
|
|
71
|
+
* split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
|
|
72
|
+
* `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
|
|
73
|
+
* database -- this is foundry's *own*). Leaving the default on would make
|
|
74
|
+
* every real call throw `UnsafeRuntimeRoleError`, since the revoke in
|
|
75
|
+
* 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
|
|
76
|
+
* actually is; the append-only guard and the chain still hold at the
|
|
77
|
+
* database level regardless (0001's triggers, 0004's hashing), only the
|
|
78
|
+
* extra fail-closed check on the *connection* is off. Options, not a second
|
|
79
|
+
* positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
|
|
80
|
+
* fixture -- can pass `true` once foundry's own database gets that split.
|
|
81
|
+
*/
|
|
82
|
+
export function createPostgresAuditLog(db, options = {}) {
|
|
83
|
+
const ledger = createLedger({
|
|
84
|
+
vocabulary: FOUNDRY_LEDGER_VOCABULARY,
|
|
85
|
+
hashChain: true,
|
|
86
|
+
checkRuntimeRole: options.checkRuntimeRole ?? false,
|
|
87
|
+
});
|
|
88
|
+
const targetFor = (orgId, target) => ({
|
|
89
|
+
type: target === 'platform' ? 'platform' : 'org',
|
|
90
|
+
id: orgId,
|
|
91
|
+
});
|
|
8
92
|
return {
|
|
9
93
|
async recordPlan({ actor, orgId, plan, target, at }) {
|
|
10
|
-
await
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
orgId,
|
|
16
|
-
plan,
|
|
17
|
-
|
|
94
|
+
await ledger.sign(db, {
|
|
95
|
+
action: 'plan.computed',
|
|
96
|
+
tenantId: null,
|
|
97
|
+
actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
|
|
98
|
+
context: 'operator',
|
|
99
|
+
target: targetFor(orgId, target),
|
|
100
|
+
after: boundPlan(plan),
|
|
101
|
+
occurredAt: at,
|
|
18
102
|
});
|
|
19
103
|
},
|
|
20
104
|
async recordApply({ actor, orgId, plan, outcome, reason, target, at }) {
|
|
21
|
-
await
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
orgId,
|
|
105
|
+
await ledger.sign(db, {
|
|
106
|
+
action: 'apply.recorded',
|
|
107
|
+
tenantId: null,
|
|
108
|
+
actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
|
|
109
|
+
context: 'operator',
|
|
110
|
+
target: targetFor(orgId, target),
|
|
27
111
|
outcome,
|
|
28
|
-
reason: reason
|
|
29
|
-
plan,
|
|
30
|
-
|
|
112
|
+
reason: boundReason(reason),
|
|
113
|
+
after: boundPlan(plan),
|
|
114
|
+
occurredAt: at,
|
|
31
115
|
});
|
|
32
116
|
},
|
|
33
117
|
async recordDestroy({ actor, orgId, outcome, reason, at }) {
|
|
34
|
-
await
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
orgId,
|
|
118
|
+
await ledger.sign(db, {
|
|
119
|
+
action: 'destroy.recorded',
|
|
120
|
+
tenantId: null,
|
|
121
|
+
actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
|
|
122
|
+
context: 'operator',
|
|
123
|
+
target: targetFor(orgId),
|
|
40
124
|
outcome,
|
|
41
|
-
reason: reason
|
|
125
|
+
reason: boundReason(reason),
|
|
126
|
+
occurredAt: at,
|
|
42
127
|
});
|
|
43
128
|
},
|
|
44
129
|
};
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { asc, eq, sql } from 'drizzle-orm';
|
|
2
|
-
import { validateBuyerIdentity, validateDomain, validateHosting, validateOwner, validateSite, } from '../org.js';
|
|
2
|
+
import { DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateDomain, validateHosting, validateMail, validateOwner, validateSite, } from '../org.js';
|
|
3
3
|
import { validatePlatform } from '../platform.js';
|
|
4
4
|
import { RequestedChangeError, classifyChange } from '../requested-changes.js';
|
|
5
5
|
import { validateSlug } from '../slug.js';
|
|
@@ -59,8 +59,15 @@ function rowToOrg(row) {
|
|
|
59
59
|
slug: row.slug,
|
|
60
60
|
displayName: row.displayName,
|
|
61
61
|
domain: row.domain,
|
|
62
|
-
mail: {
|
|
63
|
-
|
|
62
|
+
mail: {
|
|
63
|
+
mode: row.mailMode,
|
|
64
|
+
usesDomain: row.mailUsesDomain,
|
|
65
|
+
ownerMailboxAddress: row.ownerMailboxAddress,
|
|
66
|
+
noReplyLocalPart: row.noReplyLocalPart,
|
|
67
|
+
mailApps: row.mailApps,
|
|
68
|
+
roleMailboxes: row.roleMailboxes,
|
|
69
|
+
},
|
|
70
|
+
chat: { enabled: row.chatEnabled, usesDomain: row.chatUsesDomain },
|
|
64
71
|
calls: { enabled: row.callsEnabled, usesDomain: row.callsUsesDomain },
|
|
65
72
|
issuer: { usesDomain: row.issuerUsesDomain },
|
|
66
73
|
site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
|
|
@@ -73,6 +80,7 @@ function rowToOrg(row) {
|
|
|
73
80
|
buildPack: row.hostingBuildPack,
|
|
74
81
|
issuerApps: row.hostingIssuerApps,
|
|
75
82
|
accessEmails: row.hostingAccessEmails,
|
|
83
|
+
appEnv: row.hostingAppEnv,
|
|
76
84
|
},
|
|
77
85
|
sso: { enabled: row.ssoEnabled },
|
|
78
86
|
guests: { enabled: row.guestsEnabled },
|
|
@@ -96,6 +104,11 @@ function orgToRow(org) {
|
|
|
96
104
|
domain: org.domain,
|
|
97
105
|
mailMode: org.mail.mode,
|
|
98
106
|
mailUsesDomain: org.mail.usesDomain,
|
|
107
|
+
ownerMailboxAddress: org.mail.ownerMailboxAddress ?? null,
|
|
108
|
+
noReplyLocalPart: org.mail.noReplyLocalPart ?? DEFAULT_NO_REPLY_LOCAL_PART,
|
|
109
|
+
mailApps: org.mail.mailApps ?? [...DEFAULT_MAIL_APPS],
|
|
110
|
+
roleMailboxes: org.mail.roleMailboxes ?? [],
|
|
111
|
+
chatEnabled: org.chat.enabled,
|
|
99
112
|
chatUsesDomain: org.chat.usesDomain,
|
|
100
113
|
callsEnabled: org.calls.enabled,
|
|
101
114
|
callsUsesDomain: org.calls.usesDomain,
|
|
@@ -110,6 +123,7 @@ function orgToRow(org) {
|
|
|
110
123
|
hostingBuildPack: org.hosting.buildPack,
|
|
111
124
|
hostingIssuerApps: org.hosting.issuerApps,
|
|
112
125
|
hostingAccessEmails: org.hosting.accessEmails,
|
|
126
|
+
hostingAppEnv: org.hosting.appEnv ?? {},
|
|
113
127
|
ssoEnabled: org.sso.enabled,
|
|
114
128
|
guestsEnabled: org.guests.enabled,
|
|
115
129
|
soloEnabled: org.solo.enabled,
|
|
@@ -256,6 +270,16 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
256
270
|
if (reason)
|
|
257
271
|
throw new DeclaredOrgError(`owner invalid: ${reason}`);
|
|
258
272
|
}
|
|
273
|
+
// #162: same place `requireValidOwner` checks the owner, on both `create`
|
|
274
|
+
// and the merged result of `update`. Deliberately does not require
|
|
275
|
+
// `mail.ownerMailboxAddress` just because mail is on -- `validateMail`'s
|
|
276
|
+
// own doc comment says why. Also covers #34's `mailApps` check, folded
|
|
277
|
+
// into the same `validateMail`.
|
|
278
|
+
function requireValidMail(org) {
|
|
279
|
+
const reason = validateMail(org);
|
|
280
|
+
if (reason)
|
|
281
|
+
throw new DeclaredOrgError(`mail invalid: ${reason} (#162)`);
|
|
282
|
+
}
|
|
259
283
|
return {
|
|
260
284
|
async create(org) {
|
|
261
285
|
requireValidSlug(org.slug);
|
|
@@ -265,6 +289,7 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
265
289
|
requireValidHosting(org);
|
|
266
290
|
requireValidSite(org);
|
|
267
291
|
requireValidOwner(org);
|
|
292
|
+
requireValidMail(org);
|
|
268
293
|
try {
|
|
269
294
|
await db.insert(declaredOrgs).values(orgToRow(org));
|
|
270
295
|
}
|
|
@@ -289,6 +314,12 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
289
314
|
const [row] = await db.select().from(declaredOrgs).where(eq(declaredOrgs.id, id)).limit(1);
|
|
290
315
|
return row ? rowToOrg(row) : null;
|
|
291
316
|
},
|
|
317
|
+
// #133: ordered by display name, the same `asc(...)` shape
|
|
318
|
+
// `createPostgresRequestedChangeStore.listForOrg` already uses below.
|
|
319
|
+
async list() {
|
|
320
|
+
const rows = await db.select().from(declaredOrgs).orderBy(asc(declaredOrgs.displayName));
|
|
321
|
+
return rows.map(rowToOrg);
|
|
322
|
+
},
|
|
292
323
|
async slugTaken(slug) {
|
|
293
324
|
const [row] = await db
|
|
294
325
|
.select({ id: declaredOrgs.id })
|
|
@@ -338,6 +369,7 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
338
369
|
requireValidHosting(next);
|
|
339
370
|
requireValidSite(next);
|
|
340
371
|
requireValidOwner(next);
|
|
372
|
+
requireValidMail(next);
|
|
341
373
|
try {
|
|
342
374
|
await db
|
|
343
375
|
.update(declaredOrgs)
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IssuerApp } from '../org.js';
|
|
1
|
+
import type { AppEnvSource, IssuerApp } from '../org.js';
|
|
2
2
|
import type { Plan } from '../plan.js';
|
|
3
3
|
import type { Checkpoint } from '../state.js';
|
|
4
4
|
/**
|
|
@@ -113,6 +113,95 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
113
113
|
identity: undefined;
|
|
114
114
|
generated: undefined;
|
|
115
115
|
}, {}, {}>;
|
|
116
|
+
ownerMailboxAddress: import("drizzle-orm/pg-core").PgColumn<{
|
|
117
|
+
name: "owner_mailbox_address";
|
|
118
|
+
tableName: "foundry_declared_orgs";
|
|
119
|
+
dataType: "string";
|
|
120
|
+
columnType: "PgText";
|
|
121
|
+
data: string;
|
|
122
|
+
driverParam: string;
|
|
123
|
+
notNull: false;
|
|
124
|
+
hasDefault: false;
|
|
125
|
+
isPrimaryKey: false;
|
|
126
|
+
isAutoincrement: false;
|
|
127
|
+
hasRuntimeDefault: false;
|
|
128
|
+
enumValues: [string, ...string[]];
|
|
129
|
+
baseColumn: never;
|
|
130
|
+
identity: undefined;
|
|
131
|
+
generated: undefined;
|
|
132
|
+
}, {}, {}>;
|
|
133
|
+
noReplyLocalPart: import("drizzle-orm/pg-core").PgColumn<{
|
|
134
|
+
name: "no_reply_local_part";
|
|
135
|
+
tableName: "foundry_declared_orgs";
|
|
136
|
+
dataType: "string";
|
|
137
|
+
columnType: "PgText";
|
|
138
|
+
data: string;
|
|
139
|
+
driverParam: string;
|
|
140
|
+
notNull: true;
|
|
141
|
+
hasDefault: true;
|
|
142
|
+
isPrimaryKey: false;
|
|
143
|
+
isAutoincrement: false;
|
|
144
|
+
hasRuntimeDefault: false;
|
|
145
|
+
enumValues: [string, ...string[]];
|
|
146
|
+
baseColumn: never;
|
|
147
|
+
identity: undefined;
|
|
148
|
+
generated: undefined;
|
|
149
|
+
}, {}, {}>;
|
|
150
|
+
mailApps: import("drizzle-orm/pg-core").PgColumn<{
|
|
151
|
+
name: "mail_apps";
|
|
152
|
+
tableName: "foundry_declared_orgs";
|
|
153
|
+
dataType: "json";
|
|
154
|
+
columnType: "PgJsonb";
|
|
155
|
+
data: string[];
|
|
156
|
+
driverParam: unknown;
|
|
157
|
+
notNull: true;
|
|
158
|
+
hasDefault: true;
|
|
159
|
+
isPrimaryKey: false;
|
|
160
|
+
isAutoincrement: false;
|
|
161
|
+
hasRuntimeDefault: false;
|
|
162
|
+
enumValues: undefined;
|
|
163
|
+
baseColumn: never;
|
|
164
|
+
identity: undefined;
|
|
165
|
+
generated: undefined;
|
|
166
|
+
}, {}, {
|
|
167
|
+
$type: string[];
|
|
168
|
+
}>;
|
|
169
|
+
roleMailboxes: import("drizzle-orm/pg-core").PgColumn<{
|
|
170
|
+
name: "role_mailboxes";
|
|
171
|
+
tableName: "foundry_declared_orgs";
|
|
172
|
+
dataType: "json";
|
|
173
|
+
columnType: "PgJsonb";
|
|
174
|
+
data: string[];
|
|
175
|
+
driverParam: unknown;
|
|
176
|
+
notNull: true;
|
|
177
|
+
hasDefault: true;
|
|
178
|
+
isPrimaryKey: false;
|
|
179
|
+
isAutoincrement: false;
|
|
180
|
+
hasRuntimeDefault: false;
|
|
181
|
+
enumValues: undefined;
|
|
182
|
+
baseColumn: never;
|
|
183
|
+
identity: undefined;
|
|
184
|
+
generated: undefined;
|
|
185
|
+
}, {}, {
|
|
186
|
+
$type: string[];
|
|
187
|
+
}>;
|
|
188
|
+
chatEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
189
|
+
name: "chat_enabled";
|
|
190
|
+
tableName: "foundry_declared_orgs";
|
|
191
|
+
dataType: "boolean";
|
|
192
|
+
columnType: "PgBoolean";
|
|
193
|
+
data: boolean;
|
|
194
|
+
driverParam: boolean;
|
|
195
|
+
notNull: true;
|
|
196
|
+
hasDefault: true;
|
|
197
|
+
isPrimaryKey: false;
|
|
198
|
+
isAutoincrement: false;
|
|
199
|
+
hasRuntimeDefault: false;
|
|
200
|
+
enumValues: undefined;
|
|
201
|
+
baseColumn: never;
|
|
202
|
+
identity: undefined;
|
|
203
|
+
generated: undefined;
|
|
204
|
+
}, {}, {}>;
|
|
116
205
|
chatUsesDomain: import("drizzle-orm/pg-core").PgColumn<{
|
|
117
206
|
name: "chat_uses_domain";
|
|
118
207
|
tableName: "foundry_declared_orgs";
|
|
@@ -355,6 +444,25 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
355
444
|
}, {}, {
|
|
356
445
|
$type: string[];
|
|
357
446
|
}>;
|
|
447
|
+
hostingAppEnv: import("drizzle-orm/pg-core").PgColumn<{
|
|
448
|
+
name: "hosting_app_env";
|
|
449
|
+
tableName: "foundry_declared_orgs";
|
|
450
|
+
dataType: "json";
|
|
451
|
+
columnType: "PgJsonb";
|
|
452
|
+
data: Record<string, AppEnvSource>;
|
|
453
|
+
driverParam: unknown;
|
|
454
|
+
notNull: true;
|
|
455
|
+
hasDefault: true;
|
|
456
|
+
isPrimaryKey: false;
|
|
457
|
+
isAutoincrement: false;
|
|
458
|
+
hasRuntimeDefault: false;
|
|
459
|
+
enumValues: undefined;
|
|
460
|
+
baseColumn: never;
|
|
461
|
+
identity: undefined;
|
|
462
|
+
generated: undefined;
|
|
463
|
+
}, {}, {
|
|
464
|
+
$type: Record<string, AppEnvSource>;
|
|
465
|
+
}>;
|
|
358
466
|
ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
359
467
|
name: "sso_enabled";
|
|
360
468
|
tableName: "foundry_declared_orgs";
|
package/dist/store/schema.js
CHANGED
|
@@ -12,6 +12,20 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
12
12
|
domain: text('domain'),
|
|
13
13
|
mailMode: text('mail_mode').notNull().default('off'),
|
|
14
14
|
mailUsesDomain: boolean('mail_uses_domain').notNull().default(false),
|
|
15
|
+
// MailConfig (org.ts, #162). null until an operator declares one -- the
|
|
16
|
+
// mailbox itself is blocked, not guessed, until it is.
|
|
17
|
+
ownerMailboxAddress: text('owner_mailbox_address'),
|
|
18
|
+
// Defaults to 'no-reply', the same literal `0019_mail_owner_mailbox.sql`
|
|
19
|
+
// and org.ts's `DEFAULT_NO_REPLY_LOCAL_PART` use.
|
|
20
|
+
noReplyLocalPart: text('no_reply_local_part').notNull().default('no-reply'),
|
|
21
|
+
// MailConfig.mailApps (org.ts, #34). Which ZITADEL apps in the org's
|
|
22
|
+
// audience project may read a person's mail; defaults to DEFAULT_MAIL_APPS
|
|
23
|
+
// ('agora', 'webmail') so every existing org keeps its current behaviour.
|
|
24
|
+
mailApps: jsonb('mail_apps').notNull().default(['agora', 'webmail']).$type(),
|
|
25
|
+
// MailConfig.roleMailboxes (org.ts, #230). Local parts of the role mailboxes
|
|
26
|
+
// pre-created on the org's mail domain; empty by default.
|
|
27
|
+
roleMailboxes: jsonb('role_mailboxes').notNull().default([]).$type(),
|
|
28
|
+
chatEnabled: boolean('chat_enabled').notNull().default(false),
|
|
15
29
|
chatUsesDomain: boolean('chat_uses_domain').notNull().default(false),
|
|
16
30
|
callsEnabled: boolean('calls_enabled').notNull().default(false),
|
|
17
31
|
callsUsesDomain: boolean('calls_uses_domain').notNull().default(false),
|
|
@@ -28,6 +42,12 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
28
42
|
hostingBuildPack: text('hosting_build_pack').notNull().default('nixpacks'),
|
|
29
43
|
hostingIssuerApps: jsonb('hosting_issuer_apps').notNull().default([]).$type(),
|
|
30
44
|
hostingAccessEmails: jsonb('hosting_access_emails').notNull().default([]).$type(),
|
|
45
|
+
// HostingConfig.appEnv (org.ts, #243). Maps the app's own env var names to
|
|
46
|
+
// values the plan already mints; empty by default.
|
|
47
|
+
hostingAppEnv: jsonb('hosting_app_env')
|
|
48
|
+
.notNull()
|
|
49
|
+
.default({})
|
|
50
|
+
.$type(),
|
|
31
51
|
ssoEnabled: boolean('sso_enabled').notNull().default(false),
|
|
32
52
|
guestsEnabled: boolean('guests_enabled').notNull().default(false),
|
|
33
53
|
soloEnabled: boolean('solo_enabled').notNull().default(false),
|
package/dist/store.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ import type { AppliedState } from './state.js';
|
|
|
14
14
|
export interface DeclaredOrgStore {
|
|
15
15
|
create(org: DeclaredOrg): Promise<void>;
|
|
16
16
|
get(id: OrgId): Promise<DeclaredOrg | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Every declared org, ordered by display name (#133). The read path a
|
|
19
|
+
* billing admin area (or anything else that just wants the roster) uses
|
|
20
|
+
* instead of reading the store's own table by hand -- Archon's
|
|
21
|
+
* `listCustomers` was doing exactly that before this existed.
|
|
22
|
+
*/
|
|
23
|
+
list(): Promise<DeclaredOrg[]>;
|
|
17
24
|
/** Whether any org already holds this slug. `slugFromName` asks it. */
|
|
18
25
|
slugTaken(slug: string): Promise<boolean>;
|
|
19
26
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
|
-
"files": ["dist"],
|
|
12
|
+
"files": ["dist", "versions.json"],
|
|
13
13
|
"exports": {
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
@@ -37,10 +37,12 @@
|
|
|
37
37
|
"drizzle-orm": ">=0.45.2"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
+
"@wtfalch/audit": "0.6.0",
|
|
40
41
|
"@wtfalch/auth": "0.16.0",
|
|
41
42
|
"@wtfalch/email": "0.15.0",
|
|
42
43
|
"@wtfalch/providers": "0.6.0",
|
|
43
|
-
"ssh2": "1.17.0"
|
|
44
|
+
"ssh2": "1.17.0",
|
|
45
|
+
"zod": "4.6.5"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
48
|
"@electric-sql/pglite": "^0.5.8",
|