@wtfalch/foundry 0.6.0 → 0.8.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.
Files changed (58) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +6 -2
  5. package/dist/bootstrap/versions.d.ts +25 -0
  6. package/dist/bootstrap/versions.js +43 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  8. package/dist/calls-sfu-deploy-support.js +1 -1
  9. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  10. package/dist/calls-sfu-ssh-adapter.js +1 -1
  11. package/dist/calls-sfu.d.ts +8 -6
  12. package/dist/calls-sfu.js +9 -6
  13. package/dist/index.d.ts +3 -4
  14. package/dist/index.js +5 -2
  15. package/dist/migrations/0022_chat_enabled.sql +6 -0
  16. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  17. package/dist/migrations/0024_audit_events.sql +211 -0
  18. package/dist/migrations/0025_audit_display.sql +147 -0
  19. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  20. package/dist/migrations/0027_audit_chain.sql +170 -0
  21. package/dist/migrations/0028_audit_rls.sql +111 -0
  22. package/dist/migrations-dir.d.ts +9 -0
  23. package/dist/migrations-dir.js +10 -0
  24. package/dist/org.d.ts +24 -0
  25. package/dist/org.js +13 -0
  26. package/dist/plan/agora.d.ts +21 -0
  27. package/dist/plan/agora.js +23 -0
  28. package/dist/plan/calls-matrix.d.ts +4 -2
  29. package/dist/plan/calls-matrix.js +5 -2
  30. package/dist/plan/cloudflared-connector.d.ts +3 -2
  31. package/dist/plan/cloudflared-connector.js +5 -3
  32. package/dist/plan/diff.d.ts +18 -3
  33. package/dist/plan/diff.js +369 -42
  34. package/dist/plan/executor.d.ts +378 -44
  35. package/dist/plan/executor.js +816 -57
  36. package/dist/plan/index.d.ts +2 -0
  37. package/dist/plan/index.js +1 -0
  38. package/dist/plan/login-host.d.ts +88 -0
  39. package/dist/plan/login-host.js +73 -0
  40. package/dist/plan/plan-apply.d.ts +23 -8
  41. package/dist/plan/plan-apply.js +30 -10
  42. package/dist/plan/platform-diff.d.ts +102 -2
  43. package/dist/plan/platform-diff.js +232 -1
  44. package/dist/plan/platform-providers.d.ts +37 -2
  45. package/dist/plan/platform-providers.js +223 -36
  46. package/dist/plan/providers-readers.js +44 -1
  47. package/dist/plan.d.ts +43 -0
  48. package/dist/plan.js +16 -1
  49. package/dist/store/audit.d.ts +36 -4
  50. package/dist/store/audit.js +110 -29
  51. package/dist/store/postgres.d.ts +14 -0
  52. package/dist/store/postgres.js +17 -9
  53. package/dist/store/schema.d.ts +37 -1
  54. package/dist/store/schema.js +7 -0
  55. package/package.json +10 -5
  56. package/versions.json +8 -0
  57. package/dist/migrate.d.ts +0 -38
  58. package/dist/migrate.js +0 -47
@@ -0,0 +1,147 @@
1
+ -- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
2
+ -- same way that package's own audit-migrations CLI copies them into a host's
3
+ -- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
4
+ -- that copy, hand-placed at the next free number instead. Never edit the
5
+ -- copied SQL below; a fix ships as a new numbered file, same as upstream.
6
+ --
7
+ -- @wtfalch/audit 0.4.0: what the target and the tenant were CALLED.
8
+ --
9
+ -- Mirrors src/tables.ts. Every statement is idempotent. Applied by the host's
10
+ -- own migrate script after the host copies this file into its drizzle/
11
+ -- directory as the next number (audit-migrations); never edited there.
12
+ --
13
+ -- WHY THE NAME LIVES ON THE ROW
14
+ -- -----------------------------
15
+ -- A reader of the trail wants to know what happened to WHAT. Looking the name
16
+ -- up when the page renders answers that exactly until it matters: the file was
17
+ -- deleted, the organisation was closed, the key was revoked -- and those are
18
+ -- the rows a trail exists for. So the name is written with the row and never
19
+ -- read from anywhere else afterwards.
20
+ --
21
+ -- Both columns are nullable and stay nullable: every row written before this
22
+ -- migration has no name, and a writer that holds only an id is not made to
23
+ -- invent one. A reader shows the id when the name is null.
24
+ --
25
+ -- These are NOT a rename of target_id or tenant_id. The ids stay the identity;
26
+ -- the names are what they were called at the time, and a later rename does not
27
+ -- reach back.
28
+ --
29
+ -- ERASURE
30
+ -- -------
31
+ -- A target can be a person: an invitation's invitee, a membership's holder.
32
+ -- Its name is then personal data sitting in a row that outlives the account,
33
+ -- so audit_erase_person pseudonymises target_display exactly as it already
34
+ -- pseudonymises actor_display: on the rows it already erases, and only where
35
+ -- target_id is the subject. Which rows those are is unchanged on purpose. A
36
+ -- row whose target is a person is expected to carry subject_id, which is what
37
+ -- the match already looks at; a row that sets neither subject_id nor an email
38
+ -- in its payload was already outside erasure's reach before this file, and
39
+ -- widening the match here would erase rows 0001 deliberately did not.
40
+ -- tenant_display names an organisation, not a person, and is left alone; it is
41
+ -- therefore added to the guard's immutable list, so nothing can edit it after
42
+ -- the fact the way a correction would.
43
+
44
+ alter table audit_events add column if not exists target_display text;
45
+ alter table audit_events add column if not exists tenant_display text;
46
+
47
+ do $$ begin
48
+ alter table audit_events
49
+ add constraint audit_events_target_display_check
50
+ check (target_display is null or length(target_display) between 1 and 256);
51
+ exception when duplicate_object then null; end $$;
52
+
53
+ do $$ begin
54
+ alter table audit_events
55
+ add constraint audit_events_tenant_display_check
56
+ check (tenant_display is null or length(tenant_display) between 1 and 256);
57
+ exception when duplicate_object then null; end $$;
58
+
59
+ -- 0001's guard, with tenant_display added to the columns an update may not
60
+ -- change. target_display is absent from the list for the same reason
61
+ -- actor_display is: erasure rewrites it.
62
+ create or replace function audit_events_guard() returns trigger
63
+ language plpgsql as $$
64
+ begin
65
+ if tg_op = 'DELETE' then
66
+ raise exception 'audit_events is append-only: delete refused';
67
+ elsif tg_op = 'TRUNCATE' then
68
+ raise exception 'audit_events is append-only: truncate refused';
69
+ elsif tg_op = 'UPDATE' then
70
+ if new.id is distinct from old.id
71
+ or new.occurred_at is distinct from old.occurred_at
72
+ or new.tenant_id is distinct from old.tenant_id
73
+ or new.tenant_display is distinct from old.tenant_display
74
+ or new.actor_class is distinct from old.actor_class
75
+ or new.actor_id is distinct from old.actor_id
76
+ or new.action is distinct from old.action
77
+ or new.target_type is distinct from old.target_type
78
+ or new.target_id is distinct from old.target_id
79
+ or new.outcome is distinct from old.outcome
80
+ or new.context is distinct from old.context
81
+ or new.session_id is distinct from old.session_id
82
+ or new.reason is distinct from old.reason
83
+ or new.reference is distinct from old.reference
84
+ or new.request_id is distinct from old.request_id
85
+ or new.ip is distinct from old.ip
86
+ or new.user_agent is distinct from old.user_agent
87
+ or new.tenant_visible is distinct from old.tenant_visible
88
+ or new.schema_version is distinct from old.schema_version
89
+ or new.subject_class is distinct from old.subject_class
90
+ or new.subject_id is distinct from old.subject_id
91
+ then
92
+ raise exception 'audit_events is append-only: only actor_display, target_display, before, after and erased_at may change';
93
+ end if;
94
+ end if;
95
+ return new;
96
+ end
97
+ $$;
98
+
99
+ -- 0001's erasure function, extended to the target's name. Everything else is
100
+ -- unchanged: same subject match, same pseudonym bounds, same return.
101
+ create or replace function audit_erase_person(subject text, pseudonym text, subject_email text)
102
+ returns integer
103
+ language plpgsql
104
+ security definer
105
+ set search_path from current
106
+ as $$
107
+ declare
108
+ touched integer;
109
+ begin
110
+ if subject is null or length(subject) = 0 then
111
+ raise exception 'audit_erase_person: a subject id is required';
112
+ end if;
113
+ if pseudonym is null or length(pseudonym) = 0 or length(pseudonym) > 256 then
114
+ raise exception 'audit_erase_person: a pseudonym of 1 to 256 characters is required';
115
+ end if;
116
+ update audit_events
117
+ set actor_display = case when actor_id = subject then pseudonym else actor_display end,
118
+ target_display = case when target_id = subject then pseudonym else target_display end,
119
+ before = case when before is null then null else '{"erased":true}'::jsonb end,
120
+ after = case when after is null then null else '{"erased":true}'::jsonb end,
121
+ erased_at = now()
122
+ where erased_at is null
123
+ and (
124
+ actor_id = subject
125
+ or subject_id = subject
126
+ or (
127
+ subject_email is not null
128
+ and (before::text like '%' || subject_email || '%' or after::text like '%' || subject_email || '%')
129
+ )
130
+ );
131
+ get diagnostics touched = row_count;
132
+ return touched;
133
+ end
134
+ $$;
135
+
136
+ revoke all on function audit_erase_person(text, text, text) from public;
137
+
138
+ do $$
139
+ declare
140
+ rt text := current_database() || '_rt';
141
+ begin
142
+ if exists (select 1 from pg_roles where rolname = rt) then
143
+ execute format('revoke update, delete, truncate on audit_events from %I', rt);
144
+ execute format('grant execute on function audit_erase_person(text, text, text) to %I', rt);
145
+ end if;
146
+ end
147
+ $$;
@@ -0,0 +1,78 @@
1
+ -- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
2
+ -- same way that package's own audit-migrations CLI copies them into a host's
3
+ -- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
4
+ -- that copy, hand-placed at the next free number instead. Never edit the
5
+ -- copied SQL below; a fix ships as a new numbered file, same as upstream.
6
+ --
7
+ -- @wtfalch/audit: restore audit_erase_person's empty-email guard.
8
+ --
9
+ -- Mirrors src/ledger.ts's erase(). Every statement is idempotent. Applied by
10
+ -- the host's own migrate script after the host copies this file into its
11
+ -- drizzle/ directory as the next number (audit-migrations); never edited
12
+ -- there.
13
+ --
14
+ -- REGRESSION
15
+ -- ----------
16
+ -- 0002_display.sql's audit_erase_person dropped two things 0001_audit.sql
17
+ -- had: the `length(subject_email) > 0` guard and the lower() case-fold on
18
+ -- both the column and the match value. Without the length guard, an empty
19
+ -- string reaches the SQL LIKE match as `like '%%'`, which matches every
20
+ -- row's before/after text -- on every tenant, not just the subject's. This
21
+ -- file cannot edit 0002 (shipped migrations don't change); it re-defines
22
+ -- the function, which `create or replace` makes safe to apply on top.
23
+ --
24
+ -- Everything else about the function is unchanged from 0002: same subject
25
+ -- and subject_id match, same pseudonym bounds, same target_display and
26
+ -- actor_display pseudonymisation, same return.
27
+
28
+ create or replace function audit_erase_person(subject text, pseudonym text, subject_email text)
29
+ returns integer
30
+ language plpgsql
31
+ security definer
32
+ set search_path from current
33
+ as $$
34
+ declare
35
+ touched integer;
36
+ begin
37
+ if subject is null or length(subject) = 0 then
38
+ raise exception 'audit_erase_person: a subject id is required';
39
+ end if;
40
+ if pseudonym is null or length(pseudonym) = 0 or length(pseudonym) > 256 then
41
+ raise exception 'audit_erase_person: a pseudonym of 1 to 256 characters is required';
42
+ end if;
43
+ update audit_events
44
+ set actor_display = case when actor_id = subject then pseudonym else actor_display end,
45
+ target_display = case when target_id = subject then pseudonym else target_display end,
46
+ before = case when before is null then null else '{"erased":true}'::jsonb end,
47
+ after = case when after is null then null else '{"erased":true}'::jsonb end,
48
+ erased_at = now()
49
+ where erased_at is null
50
+ and (
51
+ actor_id = subject
52
+ or subject_id = subject
53
+ or (
54
+ subject_email is not null
55
+ and length(subject_email) > 0
56
+ and (
57
+ lower(coalesce(before::text, '')) like '%' || lower(subject_email) || '%'
58
+ or lower(coalesce(after::text, '')) like '%' || lower(subject_email) || '%'
59
+ )
60
+ )
61
+ );
62
+ get diagnostics touched = row_count;
63
+ return touched;
64
+ end
65
+ $$;
66
+
67
+ revoke all on function audit_erase_person(text, text, text) from public;
68
+
69
+ do $$
70
+ declare
71
+ rt text := current_database() || '_rt';
72
+ begin
73
+ if exists (select 1 from pg_roles where rolname = rt) then
74
+ execute format('revoke update, delete, truncate on audit_events from %I', rt);
75
+ execute format('grant execute on function audit_erase_person(text, text, text) to %I', rt);
76
+ end if;
77
+ end
78
+ $$;
@@ -0,0 +1,170 @@
1
+ -- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
2
+ -- same way that package's own audit-migrations CLI copies them into a host's
3
+ -- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
4
+ -- that copy, hand-placed at the next free number instead. Never edit the
5
+ -- copied SQL below; a fix ships as a new numbered file, same as upstream.
6
+ --
7
+ -- @wtfalch/audit: hash chaining, opt in.
8
+ --
9
+ -- Mirrors src/chain.ts and src/tables.ts. Every statement is idempotent.
10
+ -- Applied by the host's own migrate script after the host copies this file
11
+ -- into its drizzle/ directory as the next number (audit-migrations); never
12
+ -- edited there.
13
+ --
14
+ -- Nothing here forces chaining. Five nullable columns, computed and
15
+ -- verified entirely in TypeScript (src/chain.ts), the same design
16
+ -- `@wtfalch/authz`'s audit-chain.ts already shipped: no pgcrypto, no
17
+ -- trigger, because PGlite -- this package's own default test database --
18
+ -- has no pgcrypto, and a trigger cannot see the previous row's hash without
19
+ -- a race between concurrent inserts that only a lock (below) closes.
20
+ --
21
+ -- prev_hash -- the previous row's row_hash, in whatever order rows
22
+ -- were sealed at write time. null for the chain's first
23
+ -- row, and forever on a row nothing ever sealed.
24
+ -- row_hash -- sha256 over this row's own fields (chain.ts's
25
+ -- ROW_HASH_KEYS) plus prev_hash and content_hash. Never
26
+ -- changes once set; the append-only guard below still
27
+ -- refuses to.
28
+ -- content_hash -- sha256 over the row's erasable content (actor_display,
29
+ -- target_display, before, after), salted.
30
+ -- content_salt -- the salt content_hash was taken over. Set at seal
31
+ -- time, nulled by erasure: once it is gone nobody --
32
+ -- including this package -- can reproduce content_hash
33
+ -- from a guessed payload, which is what erasure means
34
+ -- here. Unlike row_hash, an erasure IS allowed to null
35
+ -- this, so the guard's allowed list grows to include it.
36
+ -- erasure_hash -- set only once content_salt is nulled: sha256 over
37
+ -- { row_hash, erased_at }, so the fact and the instant
38
+ -- of erasure stay checkable forever even though the
39
+ -- erased content no longer is.
40
+ --
41
+ -- A row written before this migration applies has row_hash null forever --
42
+ -- nothing recorded its hash at write time, so computing one now would only
43
+ -- hash what the row happens to contain today, proving nothing about
44
+ -- whether it changed since. verifyChain reports it 'unsealed', not
45
+ -- verified, the same stance 0002's target_display took on the rows before
46
+ -- it and worker_log_chain took on the rows before T255.
47
+ --
48
+ -- audit_seal_erasure(row_id, erasure_hash_value): the runtime role has no
49
+ -- UPDATE on audit_events at all (0001's revoke), so ledger.erase()'s
50
+ -- pending-erasure sweep -- setting content_salt to null and erasure_hash
51
+ -- once a chain-sealed row has been erased -- needs its own security
52
+ -- definer door back in, the same shape as audit_erase_person's.
53
+
54
+ alter table audit_events add column if not exists prev_hash text;
55
+ alter table audit_events add column if not exists row_hash text;
56
+ alter table audit_events add column if not exists content_hash text;
57
+ alter table audit_events add column if not exists content_salt text;
58
+ alter table audit_events add column if not exists erasure_hash text;
59
+
60
+ do $$ begin
61
+ alter table audit_events
62
+ add constraint audit_events_prev_hash_check
63
+ check (prev_hash is null or prev_hash ~ '^[0-9a-f]{64}$');
64
+ exception when duplicate_object then null; end $$;
65
+
66
+ do $$ begin
67
+ alter table audit_events
68
+ add constraint audit_events_row_hash_check
69
+ check (row_hash is null or row_hash ~ '^[0-9a-f]{64}$');
70
+ exception when duplicate_object then null; end $$;
71
+
72
+ do $$ begin
73
+ alter table audit_events
74
+ add constraint audit_events_content_hash_check
75
+ check (content_hash is null or content_hash ~ '^[0-9a-f]{64}$');
76
+ exception when duplicate_object then null; end $$;
77
+
78
+ do $$ begin
79
+ alter table audit_events
80
+ add constraint audit_events_erasure_hash_check
81
+ check (erasure_hash is null or erasure_hash ~ '^[0-9a-f]{64}$');
82
+ exception when duplicate_object then null; end $$;
83
+
84
+ -- A chain-sealed row not yet erased, so ledger.erase()'s pending-erasure
85
+ -- sweep finds it in an index scan rather than a table scan. Empty on a
86
+ -- host that never turns chaining on.
87
+ create index if not exists audit_events_chain_pending_idx
88
+ on audit_events (id)
89
+ where row_hash is not null and erased_at is not null and erasure_hash is null;
90
+
91
+ -- 0002's guard, with content_salt and erasure_hash added to the columns an
92
+ -- update may change: erasure nulls the first and sets the second, on a row
93
+ -- that was already erased by the same audit_erase_person call this
94
+ -- migration does not touch. prev_hash, row_hash and content_hash are
95
+ -- absent from the list on purpose -- once set, never.
96
+ create or replace function audit_events_guard() returns trigger
97
+ language plpgsql as $$
98
+ begin
99
+ if tg_op = 'DELETE' then
100
+ raise exception 'audit_events is append-only: delete refused';
101
+ elsif tg_op = 'TRUNCATE' then
102
+ raise exception 'audit_events is append-only: truncate refused';
103
+ elsif tg_op = 'UPDATE' then
104
+ if new.id is distinct from old.id
105
+ or new.occurred_at is distinct from old.occurred_at
106
+ or new.tenant_id is distinct from old.tenant_id
107
+ or new.tenant_display is distinct from old.tenant_display
108
+ or new.actor_class is distinct from old.actor_class
109
+ or new.actor_id is distinct from old.actor_id
110
+ or new.action is distinct from old.action
111
+ or new.target_type is distinct from old.target_type
112
+ or new.target_id is distinct from old.target_id
113
+ or new.outcome is distinct from old.outcome
114
+ or new.context is distinct from old.context
115
+ or new.session_id is distinct from old.session_id
116
+ or new.reason is distinct from old.reason
117
+ or new.reference is distinct from old.reference
118
+ or new.request_id is distinct from old.request_id
119
+ or new.ip is distinct from old.ip
120
+ or new.user_agent is distinct from old.user_agent
121
+ or new.tenant_visible is distinct from old.tenant_visible
122
+ or new.schema_version is distinct from old.schema_version
123
+ or new.subject_class is distinct from old.subject_class
124
+ or new.subject_id is distinct from old.subject_id
125
+ or new.prev_hash is distinct from old.prev_hash
126
+ or new.row_hash is distinct from old.row_hash
127
+ or new.content_hash is distinct from old.content_hash
128
+ then
129
+ raise exception 'audit_events is append-only: only actor_display, target_display, before, after, erased_at, content_salt and erasure_hash may change';
130
+ end if;
131
+ end if;
132
+ return new;
133
+ end
134
+ $$;
135
+
136
+ -- 0001's revoke took update off the runtime role entirely, on purpose:
137
+ -- audit_erase_person (security definer) is the one door back in. This is
138
+ -- the second one, for exactly one thing: chain-sealing a row
139
+ -- audit_erase_person already erased. It never computes a hash itself --
140
+ -- the caller does, in TypeScript (chain.ts's computeErasureHash), the same
141
+ -- design choice audit_erase_person's own absence of a hash column made
142
+ -- before this file existed -- it only writes the value it is given, and
143
+ -- only onto a row that is erased, chain-sealed, and not already sealed.
144
+ create or replace function audit_seal_erasure(row_id bigint, erasure_hash_value text)
145
+ returns void
146
+ language plpgsql
147
+ security definer
148
+ set search_path from current
149
+ as $$
150
+ begin
151
+ update audit_events
152
+ set content_salt = null,
153
+ erasure_hash = erasure_hash_value
154
+ where id = row_id
155
+ and row_hash is not null
156
+ and erased_at is not null
157
+ and erasure_hash is null;
158
+ end
159
+ $$;
160
+ revoke all on function audit_seal_erasure(bigint, text) from public;
161
+
162
+ do $$
163
+ declare
164
+ rt text := current_database() || '_rt';
165
+ begin
166
+ if exists (select 1 from pg_roles where rolname = rt) then
167
+ execute format('grant execute on function audit_seal_erasure(bigint, text) to %I', rt);
168
+ end if;
169
+ end
170
+ $$;
@@ -0,0 +1,111 @@
1
+ -- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
2
+ -- same way that package's own audit-migrations CLI copies them into a host's
3
+ -- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
4
+ -- that copy, hand-placed at the next free number instead. Never edit the
5
+ -- copied SQL below; a fix ships as a new numbered file, same as upstream.
6
+ --
7
+ -- @wtfalch/audit: row-level security on audit_events.
8
+ --
9
+ -- Mirrors src/ledger.ts (scopeAuditTenant). Every statement is idempotent.
10
+ -- Applied by the host's own migrate script after the host copies this file
11
+ -- into its drizzle/ directory as the next number (audit-migrations); never
12
+ -- edited there.
13
+ --
14
+ -- Until this file, tenant isolation was only the `tenantId` a caller
15
+ -- remembered to pass to ledger.page(). One missed predicate showed a
16
+ -- customer another company's trail. With RLS on, the database holds the
17
+ -- line for every role that is not the table's owner -- the runtime role
18
+ -- <database>_rt, and any read-only role a host adds:
19
+ --
20
+ -- audit.tenant_id -- set per transaction by the host
21
+ -- (scopeAuditTenant, or
22
+ -- set_config('audit.tenant_id', $1, true)).
23
+ -- While set, a SELECT sees that tenant's rows and
24
+ -- nothing else -- not another tenant's, and not
25
+ -- the estate's own tenant_id-null rows.
26
+ -- audit.require_tenant -- 'on' makes an unscoped SELECT see nothing
27
+ -- instead of everything. Set it on a role, once:
28
+ -- alter role <database>_rt set audit.require_tenant = 'on';
29
+ -- That is the setting that survives a forgotten
30
+ -- scope. It is not set here because an operator
31
+ -- surface reads across tenants on purpose, and
32
+ -- which role does that is the host's decision.
33
+ --
34
+ -- Unset and not required, a SELECT sees every row, exactly as before this
35
+ -- file: applying it changes nothing for a host until the host scopes.
36
+ --
37
+ -- INSERT is not restricted: a request scoped to one tenant may still record
38
+ -- an estate-level (tenant_id null) event, and the ledger's own vocabulary
39
+ -- checks already decide what may be written. UPDATE and DELETE have no
40
+ -- policy, so they stay refused for the runtime role -- which 0001's revoke
41
+ -- already did.
42
+ --
43
+ -- Not FORCE: the owner, and the security definer functions it owns
44
+ -- (audit_erase_person, audit_seal_erasure, and the two below), bypass RLS.
45
+ -- Erasure is by subject, across tenants, by design.
46
+
47
+ alter table audit_events enable row level security;
48
+
49
+ drop policy if exists audit_events_read on audit_events;
50
+ create policy audit_events_read on audit_events
51
+ for select
52
+ using (
53
+ case
54
+ when coalesce(current_setting('audit.tenant_id', true), '') <> ''
55
+ then tenant_id is not null
56
+ and tenant_id = current_setting('audit.tenant_id', true)::uuid
57
+ when coalesce(current_setting('audit.require_tenant', true), '') = 'on'
58
+ then false
59
+ else true
60
+ end
61
+ );
62
+
63
+ drop policy if exists audit_events_append on audit_events;
64
+ create policy audit_events_append on audit_events
65
+ for insert
66
+ with check (true);
67
+
68
+ -- The hash chain is one chain across every tenant. Under a tenant scope the
69
+ -- runtime role can no longer see the true tail, so ledger.sign() with
70
+ -- hashChain on would chain onto that tenant's last row and fork the chain.
71
+ -- This reads the tail as the owner. It returns a hash, never content.
72
+ create or replace function audit_chain_tail()
73
+ returns text
74
+ language sql
75
+ stable
76
+ security definer
77
+ set search_path from current
78
+ as $$
79
+ select row_hash from audit_events order by id desc limit 1
80
+ $$;
81
+ revoke all on function audit_chain_tail() from public;
82
+
83
+ -- The same for ledger.erase()'s pending-erasure sweep, which must find
84
+ -- every tenant's pending rows, scoped or not. Ids, hashes and erasure
85
+ -- instants only.
86
+ create or replace function audit_pending_erasures()
87
+ returns table (id bigint, row_hash text, erased_at timestamptz)
88
+ language sql
89
+ stable
90
+ security definer
91
+ set search_path from current
92
+ as $$
93
+ select e.id, e.row_hash, e.erased_at
94
+ from audit_events e
95
+ where e.row_hash is not null
96
+ and e.erased_at is not null
97
+ and e.erasure_hash is null
98
+ order by e.id
99
+ $$;
100
+ revoke all on function audit_pending_erasures() from public;
101
+
102
+ do $$
103
+ declare
104
+ rt text := current_database() || '_rt';
105
+ begin
106
+ if exists (select 1 from pg_roles where rolname = rt) then
107
+ execute format('grant execute on function audit_chain_tail() to %I', rt);
108
+ execute format('grant execute on function audit_pending_erasures() to %I', rt);
109
+ end if;
110
+ end
111
+ $$;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Absolute path of the directory holding this package's numbered `.sql`
3
+ * migrations. The host passes it to `runMigrationSources` as
4
+ * `{ name: 'foundry', dir: migrationsDir }`.
5
+ *
6
+ * Its own subpath (`@wtfalch/foundry/migrations-dir`) and no other imports: a
7
+ * migration image imports this without the rest of the package.
8
+ */
9
+ export declare const migrationsDir: string;
@@ -0,0 +1,10 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ /**
3
+ * Absolute path of the directory holding this package's numbered `.sql`
4
+ * migrations. The host passes it to `runMigrationSources` as
5
+ * `{ name: 'foundry', dir: migrationsDir }`.
6
+ *
7
+ * Its own subpath (`@wtfalch/foundry/migrations-dir`) and no other imports: a
8
+ * migration image imports this without the rest of the package.
9
+ */
10
+ export const migrationsDir = fileURLToPath(new URL('./migrations/', import.meta.url));
package/dist/org.d.ts CHANGED
@@ -99,6 +99,12 @@ export declare const DEFAULT_NO_REPLY_LOCAL_PART = "no-reply";
99
99
  * Never fired by `domain` alone (#7 amendment 1).
100
100
  */
101
101
  export interface ChatConfig {
102
+ /**
103
+ * Chat is not in v1 (decided 2026-10-01): off by default for every org.
104
+ * While off, `apply` plans no chat step and leaves a live chat resource
105
+ * alone, listed in `unmanaged`; Agora hides all chat and call UI.
106
+ */
107
+ enabled: boolean;
102
108
  usesDomain: boolean;
103
109
  }
104
110
  /**
@@ -214,7 +220,25 @@ export interface HostingConfig {
214
220
  * means no Access gate. Requires `enabled`.
215
221
  */
216
222
  accessEmails: string[];
223
+ /**
224
+ * Maps the app's own env var name to one of `AppEnvSource`'s values --
225
+ * each a value the plan already mints for this org (Files, AI, mail) but
226
+ * never used to push an app's own env (#243). Each bespoke app names these
227
+ * differently (lokessmie uses `STORAGE_*`/`AI_*`); this is how an org
228
+ * declares its own app's names for them. `{}` means the app names none of
229
+ * them, so `pushAppEnv` pushes nothing from this map.
230
+ */
231
+ appEnv: Record<string, AppEnvSource>;
217
232
  }
233
+ /**
234
+ * The closed set of values the plan already mints that `HostingConfig.appEnv`
235
+ * may map an app's env var name to (#243): the org's own Files organisation
236
+ * id, its AI organisation id, and the mail domain its Lettermint project
237
+ * sends from. Each is read from `StepExecutionContext.recordedIds()` in
238
+ * `executor.ts`'s `pushAppEnv`, never minted again there.
239
+ */
240
+ export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain';
241
+ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
218
242
  /**
219
243
  * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
220
244
  * shape as `validateBuyerIdentity` below.
package/dist/org.js CHANGED
@@ -2,6 +2,11 @@
2
2
  export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
3
3
  /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
4
4
  export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
5
+ export const APP_ENV_SOURCES = new Set([
6
+ 'filesOrganisationId',
7
+ 'aiOrganisationId',
8
+ 'mailDomain',
9
+ ]);
5
10
  const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
6
11
  const HOSTING_BUILD_PACKS = new Set([
7
12
  'dockerfile',
@@ -62,6 +67,14 @@ export function validateHosting(hosting) {
62
67
  return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
63
68
  }
64
69
  }
70
+ for (const [envName, source] of Object.entries(hosting.appEnv)) {
71
+ if (!envName) {
72
+ return 'every appEnv key needs a non-empty env var name';
73
+ }
74
+ if (!APP_ENV_SOURCES.has(source)) {
75
+ return `appEnv '${envName}' has an unknown source '${source}'`;
76
+ }
77
+ }
65
78
  return null;
66
79
  }
67
80
  /**
@@ -13,6 +13,27 @@ import type { DeclaredOrg, OrgId } from '../org.js';
13
13
  */
14
14
  export declare const ZITADEL_ORG_SCOPE_KEY = "zitadel_org";
15
15
  export declare const AGORA_CLIENT_ID_SCOPE_KEY = "agora_client_id";
16
+ /**
17
+ * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
18
+ * not a `system:kind:key` resource id (that slot already holds the app's
19
+ * `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
20
+ * is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
21
+ * above. A level-1 org declares exactly one web `issuerApps` entry for its
22
+ * one standard app, so one flat key is enough; an org with more than one web
23
+ * app would need a richer key, not built here (Finding, this campaign's
24
+ * report).
25
+ */
26
+ export declare const APP_CLIENT_ID_SCOPE_KEY = "zitadel_app_client_id";
27
+ /**
28
+ * #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
29
+ * separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
30
+ * org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
31
+ * `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
32
+ * plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
33
+ * the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
34
+ * app.
35
+ */
36
+ export declare const CMS_CLIENT_ID_SCOPE_KEY = "cms_client_id";
16
37
  /**
17
38
  * The accent every org's Agora wears until orgs choose their own: the house
18
39
  * theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
@@ -12,6 +12,27 @@
12
12
  */
13
13
  export const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
14
14
  export const AGORA_CLIENT_ID_SCOPE_KEY = 'agora_client_id';
15
+ /**
16
+ * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
17
+ * not a `system:kind:key` resource id (that slot already holds the app's
18
+ * `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
19
+ * is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
20
+ * above. A level-1 org declares exactly one web `issuerApps` entry for its
21
+ * one standard app, so one flat key is enough; an org with more than one web
22
+ * app would need a richer key, not built here (Finding, this campaign's
23
+ * report).
24
+ */
25
+ export const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
26
+ /**
27
+ * #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
28
+ * separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
29
+ * org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
30
+ * `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
31
+ * plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
32
+ * the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
33
+ * app.
34
+ */
35
+ export const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
15
36
  /**
16
37
  * The accent every org's Agora wears until orgs choose their own: the house
17
38
  * theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
@@ -85,6 +106,8 @@ export function desiredAgoraEnv(org, recorded, host) {
85
106
  organisationId: zitadelOrgId,
86
107
  mailProjectId: host.mailProjectId,
87
108
  messagingProjectId: host.messagingProjectId,
109
+ // Chat is not in v1: Agora hides all chat UI unless this is true.
110
+ chat: { enabled: org.chat.enabled },
88
111
  });
89
112
  }
90
113
  if (org.mail.mode !== 'off' && host.mailUpstream) {
@@ -45,8 +45,10 @@
45
45
  * (`executor.ts`'s `callsHostnameFor`).
46
46
  */
47
47
  export declare const SYNAPSE_IMAGE = "ghcr.io/element-hq/synapse";
48
- export declare const SYNAPSE_TAG = "v1.161.0";
49
- export declare const MAS_IMAGE = "ghcr.io/element-hq/matrix-authentication-service:1.24.0";
48
+ export declare const SYNAPSE_TAG: string;
49
+ export declare const MAS_IMAGE: string;
50
+ /** #251: the version baked into {@link MAS_IMAGE}'s `FROM` line, named on its own so a version-change line can read it without re-parsing the image string. */
51
+ export declare const MAS_TAG: string;
50
52
  /**
51
53
  * Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
52
54
  * `federation_domain_whitelist: []`) -- purely a logical identifier MAS and