@wtfalch/foundry 0.6.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.
Files changed (45) hide show
  1. package/dist/bootstrap/declare.d.ts +3 -1
  2. package/dist/bootstrap/declare.js +6 -2
  3. package/dist/bootstrap/versions.d.ts +17 -0
  4. package/dist/bootstrap/versions.js +12 -0
  5. package/dist/calls-sfu.d.ts +5 -4
  6. package/dist/calls-sfu.js +6 -4
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +1 -1
  9. package/dist/migrations/0022_chat_enabled.sql +6 -0
  10. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  11. package/dist/migrations/0024_audit_events.sql +211 -0
  12. package/dist/migrations/0025_audit_display.sql +147 -0
  13. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  14. package/dist/migrations/0027_audit_chain.sql +170 -0
  15. package/dist/migrations/0028_audit_rls.sql +111 -0
  16. package/dist/org.d.ts +24 -0
  17. package/dist/org.js +13 -0
  18. package/dist/plan/agora.d.ts +21 -0
  19. package/dist/plan/agora.js +23 -0
  20. package/dist/plan/calls-matrix.d.ts +2 -2
  21. package/dist/plan/calls-matrix.js +3 -2
  22. package/dist/plan/cloudflared-connector.d.ts +3 -2
  23. package/dist/plan/cloudflared-connector.js +5 -3
  24. package/dist/plan/diff.d.ts +15 -2
  25. package/dist/plan/diff.js +306 -41
  26. package/dist/plan/executor.d.ts +73 -28
  27. package/dist/plan/executor.js +217 -55
  28. package/dist/plan/index.d.ts +2 -0
  29. package/dist/plan/index.js +1 -0
  30. package/dist/plan/login-host.d.ts +64 -0
  31. package/dist/plan/login-host.js +52 -0
  32. package/dist/plan/plan-apply.d.ts +21 -8
  33. package/dist/plan/plan-apply.js +10 -9
  34. package/dist/plan/platform-diff.d.ts +23 -1
  35. package/dist/plan/platform-diff.js +35 -0
  36. package/dist/plan/platform-providers.d.ts +1 -1
  37. package/dist/plan/platform-providers.js +71 -16
  38. package/dist/plan/providers-readers.js +44 -1
  39. package/dist/store/audit.d.ts +40 -4
  40. package/dist/store/audit.js +114 -29
  41. package/dist/store/postgres.js +4 -1
  42. package/dist/store/schema.d.ts +37 -1
  43. package/dist/store/schema.js +7 -0
  44. package/package.json +5 -3
  45. package/versions.json +8 -0
@@ -7,8 +7,10 @@ export declare const DEFAULT_GIT_REF = "main";
7
7
  * The Stalwart image tag the platform's mail step runs. `platform.mail.gitRef`
8
8
  * is an image tag, not a git ref, and the planner blocks a floating one
9
9
  * (`platform-providers.ts`, `mailImageTagBlocked`), so this is a pinned release.
10
+ * Pinned in `versions.json` as `stalwart` (#247) -- this is the pattern that
11
+ * file's loader follows.
10
12
  */
11
- export declare const DEFAULT_MAIL_IMAGE_TAG = "v0.16.24";
13
+ export declare const DEFAULT_MAIL_IMAGE_TAG: string;
12
14
  export interface BootstrapCompany {
13
15
  name: string;
14
16
  slug: string;
@@ -1,5 +1,6 @@
1
1
  import { validateOwner } from '../org.js';
2
2
  import { PLATFORM_SERVICE_KEYS, defaultPlatformHostname } from '../platform.js';
3
+ import { VERSIONS } from './versions.js';
3
4
  /** Company Included's own org id, the same literal `test/fakes.ts`'s `basePlatform()` uses for `ownerOrgId`. */
4
5
  export const COMPANY_INCLUDED_ORG_ID = 'org_company_included';
5
6
  export const DEFAULT_GIT_REF = 'main';
@@ -7,8 +8,10 @@ export const DEFAULT_GIT_REF = 'main';
7
8
  * The Stalwart image tag the platform's mail step runs. `platform.mail.gitRef`
8
9
  * is an image tag, not a git ref, and the planner blocks a floating one
9
10
  * (`platform-providers.ts`, `mailImageTagBlocked`), so this is a pinned release.
11
+ * Pinned in `versions.json` as `stalwart` (#247) -- this is the pattern that
12
+ * file's loader follows.
10
13
  */
11
- export const DEFAULT_MAIL_IMAGE_TAG = 'v0.16.24';
14
+ export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
12
15
  /**
13
16
  * Every platform service except `sfu` is enabled, like the other steps: a
14
17
  * service whose dependencies the host did not supply is reported blocked, not
@@ -55,11 +58,12 @@ export function declaredCompanyOrg(company, owner) {
55
58
  ownerMailboxAddress: null,
56
59
  noReplyLocalPart: 'no-reply',
57
60
  },
58
- chat: { usesDomain: false },
61
+ chat: { enabled: false, usesDomain: false },
59
62
  calls: { enabled: false, usesDomain: false },
60
63
  issuer: { usesDomain: false },
61
64
  site: { enabled: false, usesDomain: false },
62
65
  hosting: {
66
+ appEnv: {},
63
67
  enabled: false,
64
68
  usesDomain: false,
65
69
  database: false,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The estate's pinned third-party image/release refs for the platform
3
+ * services foundry provisions (#247). One JSON file, so Renovate can read
4
+ * and later propose bumps against it, instead of four separate string
5
+ * constants it cannot see. `postgres` has no entry here: no planner step
6
+ * pins a postgres image tag today, so there is no ref to record yet.
7
+ */
8
+ export interface PinnedVersions {
9
+ zitadel: string;
10
+ synapse: string;
11
+ mas: string;
12
+ livekit: string;
13
+ cloudflared: string;
14
+ stalwart: string;
15
+ }
16
+ export declare function loadVersions(): PinnedVersions;
17
+ export declare const VERSIONS: PinnedVersions;
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /**
3
+ * Read at module load from `versions.json`, which sits beside this
4
+ * package's `package.json` (not under `src`, so plain JSON import would
5
+ * fight `tsconfig.build.json`'s `rootDir`) and ships in the published
6
+ * tarball via `package.json`'s `files`.
7
+ */
8
+ const VERSIONS_PATH = new URL('../../versions.json', import.meta.url);
9
+ export function loadVersions() {
10
+ return JSON.parse(readFileSync(VERSIONS_PATH, 'utf8'));
11
+ }
12
+ export const VERSIONS = loadVersions();
@@ -116,11 +116,12 @@ export declare const CLOUDFLARED_ENV_PATH = "/opt/livekit/cloudflared.env";
116
116
  /**
117
117
  * `:latest`, matching this codebase's one other pinned-by-tag-only image
118
118
  * (`scripts/shared-ingress.mjs`'s `CLOUDFLARED_IMAGE_REF`) -- neither runs
119
- * anywhere a supply-chain review has asked for a digest pin yet.
119
+ * anywhere a supply-chain review has asked for a digest pin yet. Pinned in
120
+ * `versions.json` (#247).
120
121
  */
121
- export declare const LIVEKIT_IMAGE_REF = "livekit/livekit-server:latest";
122
- /** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). */
123
- export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:2026.9.3";
122
+ export declare const LIVEKIT_IMAGE_REF: string;
123
+ /** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). Pinned in `versions.json` (#247). */
124
+ export declare const CLOUDFLARED_IMAGE_REF: string;
124
125
  /** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
125
126
  export declare const LIVEKIT_SIGNALLING_PORT = 7880;
126
127
  /** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
package/dist/calls-sfu.js CHANGED
@@ -52,6 +52,7 @@
52
52
  * `calls-sfu-deploy-support.ts`'s `fileWriteMode`), never logged, scrubbed
53
53
  * from any error `calls-sfu-deploy.mjs` prints.
54
54
  */
55
+ import { VERSIONS } from './bootstrap/versions.js';
55
56
  // ---------------------------------------------------------------------------
56
57
  // The guard
57
58
  // ---------------------------------------------------------------------------
@@ -105,11 +106,12 @@ export const CLOUDFLARED_ENV_PATH = '/opt/livekit/cloudflared.env';
105
106
  /**
106
107
  * `:latest`, matching this codebase's one other pinned-by-tag-only image
107
108
  * (`scripts/shared-ingress.mjs`'s `CLOUDFLARED_IMAGE_REF`) -- neither runs
108
- * anywhere a supply-chain review has asked for a digest pin yet.
109
+ * anywhere a supply-chain review has asked for a digest pin yet. Pinned in
110
+ * `versions.json` (#247).
109
111
  */
110
- export const LIVEKIT_IMAGE_REF = 'livekit/livekit-server:latest';
111
- /** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). */
112
- export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:2026.9.3';
112
+ export const LIVEKIT_IMAGE_REF = `livekit/livekit-server:${VERSIONS.livekit}`;
113
+ /** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). Pinned in `versions.json` (#247). */
114
+ export const CLOUDFLARED_IMAGE_REF = `cloudflare/cloudflared:${VERSIONS.cloudflared}`;
113
115
  /** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
114
116
  export const LIVEKIT_SIGNALLING_PORT = 7880;
115
117
  /** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
package/dist/index.d.ts CHANGED
@@ -20,8 +20,8 @@ export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
20
20
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
21
21
  export type { PlatformOrgClientFactories } from './plan/index.js';
22
22
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
23
- export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, } from './plan/index.js';
24
- export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
23
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, } from './plan/index.js';
24
+ export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
25
25
  export { HttpJobsSchedulerClient } from './plan/index.js';
26
26
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
27
27
  export { checkMailIdentityAudience } from './plan/index.js';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredP
8
8
  export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
9
9
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
10
10
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
11
- export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
11
+ export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
12
12
  export { HttpJobsSchedulerClient } from './plan/index.js';
13
13
  export { checkMailIdentityAudience } from './plan/index.js';
14
14
  export { checkMailAppsAllowlist } from './plan/index.js';
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: chat is not in v1 (decided 2026-10-01). Mirrors
2
+ -- `src/store/schema.ts`'s `chatEnabled`. Defaults false, so every existing org
3
+ -- reads as chat off and `apply` leaves any live chat resource alone.
4
+
5
+ alter table foundry_declared_orgs
6
+ add column if not exists chat_enabled boolean not null default false;
@@ -0,0 +1,8 @@
1
+ -- @wtfalch/foundry: the bespoke app's own env var names for values the plan
2
+ -- already mints (Files, AI, mail), each a key `pushAppEnv` resolves (#243).
3
+ -- Mirrors `src/store/schema.ts`'s `hostingAppEnv`. Defaults to an empty
4
+ -- object so every existing org keeps its current behaviour: no app env
5
+ -- pushed from this map.
6
+
7
+ alter table foundry_declared_orgs
8
+ add column if not exists hosting_app_env jsonb not null default '{}'::jsonb;
@@ -0,0 +1,211 @@
1
+ -- Cutover from foundry_audit_events (0012_audit.sql): it stops receiving writes
2
+ -- from this migration on, and its old rows are left in place.
3
+ -- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
4
+ -- same way that package's own audit-migrations CLI copies them into a host's
5
+ -- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
6
+ -- that copy, hand-placed at the next free number instead. Never edit the
7
+ -- copied SQL below; a fix ships as a new numbered file, same as upstream.
8
+ --
9
+ -- @wtfalch/audit: the append-only audit ledger. Mirrors src/tables.ts.
10
+ --
11
+ -- Every statement is idempotent. Applied by the host's own migrate script
12
+ -- after the host copies this file into its drizzle/ directory as the next
13
+ -- number (audit-migrations); never edited there.
14
+ --
15
+ -- What this file enforces is the ledger's SHAPE and its WALLS: bounded
16
+ -- columns, JSON under 64 KB, the pairs that must appear together, the
17
+ -- break-glass rule that a session row carries its session, reason and
18
+ -- reference, and the three triggers plus the runtime-role revoke that make
19
+ -- "append-only" true for a role that is not a superuser. The CLOSED SETS
20
+ -- (which event names, actor classes, contexts, outcomes and reason codes a
21
+ -- host admits) are the host's own vocabulary: enforced in TypeScript by the
22
+ -- ledger at write time, and in the database by the host's own CHECKs when it
23
+ -- has them (wtfalch/app-template's 0003 and 0007 do). A host with only this
24
+ -- file gets the shape and the walls.
25
+ --
26
+ -- Estate-shaped: the DO block assumes a runtime role named <database>_rt that
27
+ -- owns nothing and serves the app. Without it, the tables and the function
28
+ -- exist and no revoke takes effect.
29
+ --
30
+ -- No foreign keys, on purpose: the trail outlives the tenant, the person and
31
+ -- the credential it describes. `tenant_id` is a uuid because the estate's
32
+ -- tenants are; a host with other ids alters the column in its own migration.
33
+
34
+ create table if not exists audit_events (
35
+ id bigint generated always as identity primary key,
36
+ occurred_at timestamptz not null default now(),
37
+ tenant_id uuid,
38
+ actor_class text not null,
39
+ actor_id text not null,
40
+ actor_display text not null,
41
+ action text not null,
42
+ target_type text not null,
43
+ target_id text not null,
44
+ outcome text not null,
45
+ context text not null,
46
+ session_id text,
47
+ reason text,
48
+ reference text,
49
+ request_id text,
50
+ ip text,
51
+ user_agent text,
52
+ tenant_visible boolean not null,
53
+ before jsonb,
54
+ after jsonb,
55
+ -- Set by audit_erase_person. The row and actor_id survive; who that was
56
+ -- does not, once this is set.
57
+ erased_at timestamptz,
58
+ schema_version smallint not null default 1,
59
+ -- The principal the event was ABOUT, when that is not the actor: an
60
+ -- invitation's invitee, a membership's holder. What lets "events affecting
61
+ -- me" be answered without guessing from payloads.
62
+ subject_class text,
63
+ subject_id text,
64
+ constraint audit_events_actor_class_check check (length(actor_class) between 1 and 64),
65
+ constraint audit_events_actor_id_check check (length(actor_id) between 1 and 256),
66
+ constraint audit_events_actor_display_check check (length(actor_display) between 1 and 256),
67
+ constraint audit_events_action_shape_check check (action ~ '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'),
68
+ constraint audit_events_target_type_check check (length(target_type) between 1 and 64),
69
+ constraint audit_events_target_id_check check (length(target_id) between 1 and 256),
70
+ constraint audit_events_outcome_check check (length(outcome) between 1 and 32),
71
+ constraint audit_events_context_check check (length(context) between 1 and 32),
72
+ constraint audit_events_session_id_check check (session_id is null or length(session_id) <= 256),
73
+ constraint audit_events_reason_check check (reason is null or length(reason) <= 512),
74
+ constraint audit_events_reference_check check (reference is null or length(reference) <= 512),
75
+ constraint audit_events_request_id_check check (request_id is null or length(request_id) <= 128),
76
+ constraint audit_events_ip_check check (ip is null or length(ip) <= 64),
77
+ constraint audit_events_user_agent_check check (user_agent is null or length(user_agent) <= 1024),
78
+ constraint audit_events_before_check check (before is null or octet_length(before::text) <= 65536),
79
+ constraint audit_events_after_check check (after is null or octet_length(after::text) <= 65536),
80
+ -- A support-session row names its session, its reason and its reference.
81
+ -- Which reasons are admissible is the host's closed set.
82
+ constraint audit_events_break_glass_shape_check check (
83
+ context <> 'break_glass'
84
+ or (session_id is not null and reason is not null and reference is not null)
85
+ ),
86
+ constraint audit_events_subject_pair_check check ((subject_id is null) = (subject_class is null)),
87
+ constraint audit_events_subject_class_check check (subject_class is null or length(subject_class) between 1 and 64),
88
+ constraint audit_events_subject_id_check check (subject_id is null or length(subject_id) between 1 and 256)
89
+ );
90
+
91
+ -- (occurred_at, id) is the ordering; every reader pages on it.
92
+ create index if not exists audit_events_tenant_time_idx
93
+ on audit_events (tenant_id, occurred_at desc, id desc);
94
+ create index if not exists audit_events_actor_time_idx
95
+ on audit_events (actor_id, occurred_at desc);
96
+ create index if not exists audit_events_action_time_idx
97
+ on audit_events (action, occurred_at desc);
98
+ create index if not exists audit_events_subject_time_idx
99
+ on audit_events (subject_class, subject_id, occurred_at desc, id desc) where subject_id is not null;
100
+ create index if not exists audit_events_request_idx
101
+ on audit_events (request_id, occurred_at desc) where request_id is not null;
102
+
103
+ -- The second wall. The first is the revoke below, which stops the server; this
104
+ -- stops the owner's own mistakes at a psql prompt. An UPDATE may change only
105
+ -- the four columns erasure touches, and DELETE and TRUNCATE are refused
106
+ -- outright. audit_erase_person passes through this trigger, not around it.
107
+ create or replace function audit_events_guard() returns trigger
108
+ language plpgsql as $$
109
+ begin
110
+ if tg_op = 'DELETE' then
111
+ raise exception 'audit_events is append-only: delete refused';
112
+ elsif tg_op = 'TRUNCATE' then
113
+ raise exception 'audit_events is append-only: truncate refused';
114
+ elsif tg_op = 'UPDATE' then
115
+ if new.id is distinct from old.id
116
+ or new.occurred_at is distinct from old.occurred_at
117
+ or new.tenant_id is distinct from old.tenant_id
118
+ or new.actor_class is distinct from old.actor_class
119
+ or new.actor_id is distinct from old.actor_id
120
+ or new.action is distinct from old.action
121
+ or new.target_type is distinct from old.target_type
122
+ or new.target_id is distinct from old.target_id
123
+ or new.outcome is distinct from old.outcome
124
+ or new.context is distinct from old.context
125
+ or new.session_id is distinct from old.session_id
126
+ or new.reason is distinct from old.reason
127
+ or new.reference is distinct from old.reference
128
+ or new.request_id is distinct from old.request_id
129
+ or new.ip is distinct from old.ip
130
+ or new.user_agent is distinct from old.user_agent
131
+ or new.tenant_visible is distinct from old.tenant_visible
132
+ or new.schema_version is distinct from old.schema_version
133
+ or new.subject_class is distinct from old.subject_class
134
+ or new.subject_id is distinct from old.subject_id
135
+ then
136
+ raise exception 'audit_events is append-only: only actor_display, before, after and erased_at may change';
137
+ end if;
138
+ end if;
139
+ return new;
140
+ end
141
+ $$;
142
+ drop trigger if exists audit_events_guarded_update on audit_events;
143
+ create trigger audit_events_guarded_update
144
+ before update on audit_events
145
+ for each row execute function audit_events_guard();
146
+ drop trigger if exists audit_events_no_delete on audit_events;
147
+ create trigger audit_events_no_delete
148
+ before delete on audit_events
149
+ for each row execute function audit_events_guard();
150
+ drop trigger if exists audit_events_no_truncate on audit_events;
151
+ create trigger audit_events_no_truncate
152
+ before truncate on audit_events
153
+ for each statement execute function audit_events_guard();
154
+
155
+ -- The one sanctioned write. Rows the person wrote (actor_id), rows written
156
+ -- about them (subject_id), and rows whose payload carries their address:
157
+ -- actor_display becomes the pseudonym on their own rows, before and after
158
+ -- are replaced wholesale on every matched row, erased_at is set. actor_id
159
+ -- and subject_id stay, so "somebody with this id did this" survives and
160
+ -- "who that was" does not. Row counts never change. Ledger-only: a host's
161
+ -- own tables (a profile, an invitation) are the host's to clean, in the same
162
+ -- transaction, and its final `person.erased` row is written after this.
163
+ create or replace function audit_erase_person(subject text, pseudonym text, subject_email text)
164
+ returns integer
165
+ language plpgsql
166
+ security definer
167
+ set search_path = pg_catalog, public
168
+ as $$
169
+ declare
170
+ touched integer;
171
+ begin
172
+ if subject is null or length(subject) = 0 then
173
+ raise exception 'audit_erase_person: a subject id is required';
174
+ end if;
175
+ if pseudonym is null or length(pseudonym) = 0 or length(pseudonym) > 256 then
176
+ raise exception 'audit_erase_person: a pseudonym of 1 to 256 characters is required';
177
+ end if;
178
+ update audit_events
179
+ set actor_display = case when actor_id = subject then pseudonym else actor_display end,
180
+ before = case when before is null then null else '{"erased":true}'::jsonb end,
181
+ after = case when after is null then null else '{"erased":true}'::jsonb end,
182
+ erased_at = now()
183
+ where erased_at is null
184
+ and (
185
+ actor_id = subject
186
+ or subject_id = subject
187
+ or (
188
+ subject_email is not null
189
+ and length(subject_email) > 0
190
+ and (
191
+ lower(coalesce(before::text, '')) like '%' || lower(subject_email) || '%'
192
+ or lower(coalesce(after::text, '')) like '%' || lower(subject_email) || '%'
193
+ )
194
+ )
195
+ );
196
+ get diagnostics touched = row_count;
197
+ return touched;
198
+ end
199
+ $$;
200
+ revoke all on function audit_erase_person(text, text, text) from public;
201
+
202
+ do $$
203
+ declare
204
+ rt text := current_database() || '_rt';
205
+ begin
206
+ if exists (select 1 from pg_roles where rolname = rt) then
207
+ execute format('revoke update, delete, truncate on audit_events from %I', rt);
208
+ execute format('grant execute on function audit_erase_person(text, text, text) to %I', rt);
209
+ end if;
210
+ end
211
+ $$;
@@ -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 = pg_catalog, public
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 = pg_catalog, public
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
+ $$;