@dooer/dooer-test-env 1.0.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 (60) hide show
  1. package/bin/index.js +7 -0
  2. package/discovery-router/Dockerfile +18 -0
  3. package/discovery-router/README.md +99 -0
  4. package/discovery-router/package.json +13 -0
  5. package/discovery-router/registry.example.json +5 -0
  6. package/discovery-router/server.js +272 -0
  7. package/lib/account.js +120 -0
  8. package/lib/auth-dev-keys.js +12 -0
  9. package/lib/bankid.js +130 -0
  10. package/lib/cli.js +27 -0
  11. package/lib/command/bankid.js +45 -0
  12. package/lib/command/customer.js +108 -0
  13. package/lib/command/db.js +156 -0
  14. package/lib/command/env.js +114 -0
  15. package/lib/command/logs.js +143 -0
  16. package/lib/command/measure.js +81 -0
  17. package/lib/command/service.js +166 -0
  18. package/lib/command/setup.js +92 -0
  19. package/lib/command/shred.js +60 -0
  20. package/lib/compose/README.md +98 -0
  21. package/lib/compose/generate.js +375 -0
  22. package/lib/compose/manifests.js +108 -0
  23. package/lib/db/roles.js +118 -0
  24. package/lib/discovery/client.js +40 -0
  25. package/lib/engine/GUIDE.md +176 -0
  26. package/lib/engine/PROCESS.md +571 -0
  27. package/lib/engine/dbbuild.js +325 -0
  28. package/lib/engine/gen-schema-map.js +479 -0
  29. package/lib/engine/purge.js +137 -0
  30. package/lib/engine/schema-map.json +11016 -0
  31. package/lib/engine/seed.js +1045 -0
  32. package/lib/obc.js +72 -0
  33. package/lib/registry.js +123 -0
  34. package/lib/runtime.js +101 -0
  35. package/lib/service-token.js +40 -0
  36. package/lib/shred/README.md +118 -0
  37. package/lib/shred/audit.js +128 -0
  38. package/lib/shred/faker.js +545 -0
  39. package/lib/shred/index.js +126 -0
  40. package/lib/shred/scripts/base-partner-emails.sql +9 -0
  41. package/lib/shred/scripts/dev-accounts.sql +195 -0
  42. package/lib/shred/scripts/emails.sql +48 -0
  43. package/lib/shred/scripts/institution-browser.sql +3 -0
  44. package/lib/shred/scripts/notification-targets.sql +5 -0
  45. package/lib/shred/scripts/partners.sql +2 -0
  46. package/lib/shred/scripts/passwords.sql +8 -0
  47. package/lib/shred/scripts/personal-numbers.sql +177 -0
  48. package/lib/shred/scripts/phone-numbers.sql +22 -0
  49. package/lib/shred/scripts/salary-spec-reports.sql +5 -0
  50. package/lib/shred/scripts/service-activity-tracker-data.sql +4 -0
  51. package/lib/shred/scripts/service-core-objects.sql +19 -0
  52. package/lib/shred/scripts/service-event-stream.sql +2 -0
  53. package/lib/shred/scripts/service-integrations.sql +4 -0
  54. package/lib/shred/scripts/template.sql +4 -0
  55. package/lib/shred/scripts/x-service-billing.sql +34 -0
  56. package/lib/shred/scripts/xxx-history-tables.sql +25 -0
  57. package/lib/stub.js +8 -0
  58. package/local-postgres/Dockerfile +11 -0
  59. package/package.json +46 -0
  60. package/readme.md +92 -0
@@ -0,0 +1,325 @@
1
+ // dbbuild.js — the customer-free base DB. Two entry points:
2
+ // build(): runs IN-CLUSTER (k8s Job). The base contains NO organizations (Jimmy 2026-09-01). It
3
+ // schema-only-restores into its OWN scratch DB, then FULL-copies every NON-org table (reference/
4
+ // global data + users) — NEVER an org-scoped table, so nothing is imported-then-deleted and the
5
+ // scratch stays tiny. Then it shreds (anonymize users), runs the PII audit (fails on any
6
+ // uncovered PII column), pg_dumps the result and uploads it to the OBC bucket. The scratch is a
7
+ // SEPARATE instance — never the dooer-database volume (which lacks room for a full copy anyway).
8
+ // pull(): runs on a DEV machine. Downloads the newest base artifact from the OBC bucket and restores it
9
+ // into the LOCAL Postgres. Never touches a k8s DB.
10
+ // See ENVIRONMENT-PLAN.md §5.
11
+ const { spawnSync } = require('child_process')
12
+ const fs = require('fs')
13
+ const os = require('os')
14
+ const path = require('path')
15
+ const { Client } = require('pg')
16
+ const copyFrom = require('pg-copy-streams').from
17
+ const copyTo = require('pg-copy-streams').to
18
+ const { pipeline } = require('stream/promises')
19
+ const engine = require('./seed')
20
+ const { purgeTables } = require('./purge')
21
+
22
+ const { filterClause, qTable, qIdent } = engine
23
+
24
+ // Connect directly. SSL defaults ON (the Zalando dooer-database rejects non-SSL via pg_hba); pass
25
+ // ssl:false for the plain scratch postgres (which has no SSL → would error "server does not support SSL").
26
+ async function connectDirect(params) {
27
+ const { ssl, ...rest } = params
28
+ const c = new Client({ ...rest, ssl: ssl === false ? undefined : { rejectUnauthorized: false } })
29
+ await c.connect()
30
+ return c
31
+ }
32
+
33
+ // default partner domains whose orgs are customers to remove (Jimmy 2026-09-01). Others are kept + shredded.
34
+ const DEFAULT_PURGE_PARTNERS = ['dooer', 'dooersales']
35
+
36
+ // Columns that mark a table as org-scoped (same OWNER set as gen-schema-map).
37
+ const ORG_COLUMNS = [
38
+ 'organizationId',
39
+ 'company_id',
40
+ 'organization_id',
41
+ 'companyId',
42
+ 'fk_companies_pk',
43
+ 'companies_pk',
44
+ 'taskOrganizationId',
45
+ 'customerOrganizationId',
46
+ ]
47
+
48
+ // Base-build exclusions BEYOND the automatic org-closure + history. These are tables with NO org column
49
+ // that are nonetheless customer-derived (events/logs/caches/cursors/dispatch) or org-scoped only through a
50
+ // reverse ref / a soft (unconstrained) FK the closure can't see — so they must be listed explicitly.
51
+ // (Jimmy's rulings 2026-09-01; extend as the plan review finalizes — see scripts/base-build-plan.js.)
52
+ const BASE_EXCLUDE_TABLES = new Set([
53
+ 'service_file_storage.fileData', // reverse-ref: all orgs' file blobs (fileEntry is already org-scoped)
54
+ 'service_event_stream.event',
55
+ 'service_automatic_booking.bookingSuggestion',
56
+ 'service_task_engine.customAttribute', // soft FK to task (org-scoped)
57
+ ])
58
+ // name patterns that are customer-derived / operational, never reference data
59
+ const BASE_EXCLUDE_PATTERNS = [
60
+ /event$/i,
61
+ /eventArchive$/i,
62
+ /\.cursor$/i,
63
+ /dispatch/i,
64
+ /requestLog$/i,
65
+ /logon_log/i,
66
+ /loginFailure/i,
67
+ /cachedData$/i,
68
+ /\.messages$/i,
69
+ /aiMatchSuggestion$/i,
70
+ /\.bdd20se_/i, // Swedish business-registry snapshot (~25M rows) — services repopulate it (Jimmy 2026-09-01)
71
+ /^service_institution_browser\./i, // bank-scraping + personnummer (skatteverket_sync_run.id_number) — security-sensitive (Jimmy 2026-09-01)
72
+ ]
73
+ const isHistory = (schema, table) => /_history$/.test(schema) || /__history$/.test(table)
74
+
75
+ // the set of org ids to purge: every org under the given partner domains, minus an allow-list.
76
+ async function customerOrgIds(client, partnerDomains, allowList) {
77
+ const r = await client.query(
78
+ `SELECT po."organizationId" AS id
79
+ FROM service_accounts."partnerOrganization" po
80
+ JOIN service_accounts.partner p ON p.id = po."partnerId"
81
+ WHERE p."domain" = ANY($1)`,
82
+ [partnerDomains]
83
+ )
84
+ const skip = new Set(allowList || [])
85
+ return r.rows.map((x) => x.id).filter((id) => id && !skip.has(id))
86
+ }
87
+
88
+ // reuse the copy/purge filter, promoted from a single-org `=$1` to an org-SET `= ANY($1)`. Works for
89
+ // owner-column, crossOrgRelation and (nested) fkFilter tables alike. extraFilter is dropped (full delete).
90
+ function filterSetSql(t) {
91
+ const f = filterClause(t, null, { includeExtra: false })
92
+ if (!f) return null
93
+ return f.sql.replace(/[=]\$1/g, '= ANY($1)')
94
+ }
95
+
96
+ // delete all data for the customer org set across every org-scoped table (FK/triggers disabled).
97
+ async function bulkPurge(
98
+ client,
99
+ { partnerDomains = DEFAULT_PURGE_PARTNERS, allowList = [], execute = false, mapPath } = {}
100
+ ) {
101
+ const map = require(mapPath || path.join(__dirname, 'schema-map.json'))
102
+ const orgIds = await customerOrgIds(client, partnerDomains, allowList)
103
+ console.log(
104
+ `customer orgs to purge (partners ${partnerDomains.join(', ')}): ${orgIds.length}${
105
+ allowList.length ? ` (allow-list spares ${allowList.length})` : ''
106
+ }`
107
+ )
108
+ if (!orgIds.length) return { orgs: 0, deleted: 0 }
109
+ const tables = purgeTables(map)
110
+ if (!execute) {
111
+ console.log(`DRY-RUN: would delete customer rows across up to ${tables.length} tables. Pass execute to run.`)
112
+ return { orgs: orgIds.length, deleted: 0 }
113
+ }
114
+ await client.query('BEGIN')
115
+ await client.query("SET session_replication_role = 'replica'")
116
+ let deleted = 0
117
+ for (const t of tables) {
118
+ const sql = filterSetSql(t)
119
+ if (!sql) continue
120
+ const exists = await client.query(
121
+ `SELECT 1 FROM information_schema.tables WHERE table_schema=$1 AND table_name=$2 AND table_type='BASE TABLE'`,
122
+ [t.schema, t.table]
123
+ )
124
+ if (!exists.rowCount) continue
125
+ const r = await client.query(`DELETE FROM ${qTable(t.schema, t.table)} WHERE ${sql}`, [orgIds])
126
+ deleted += r.rowCount
127
+ }
128
+ await client.query("SET session_replication_role = 'origin'")
129
+ await client.query('COMMIT')
130
+ console.log(`bulk purge: deleted ${deleted} rows for ${orgIds.length} customer orgs`)
131
+ return { orgs: orgIds.length, deleted }
132
+ }
133
+
134
+ // ── shell helpers for the in-cluster dump/restore ────────────────────────────
135
+ function pg(cmd, args, env) {
136
+ const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...env } })
137
+ if (r.status !== 0) throw new Error(`${cmd} failed (exit ${r.status})`)
138
+ }
139
+
140
+ function dsn({ host, port = 5432, user, password, database }) {
141
+ return `postgresql://${user}:${encodeURIComponent(password)}@${host}:${port}/${database}`
142
+ }
143
+
144
+ // Compute the base INCLUDE set from the LIVE schema: every base table that is NOT org-scoped (no org
145
+ // column and not FK-reachable into one), NOT history, and NOT in the configured excludes. Returns
146
+ // [{schema, table}]. This is the authoritative list the base copies in full (reference/global data + users).
147
+ async function computeIncludeSet(src) {
148
+ const orgColRows = (
149
+ await src.query(
150
+ `SELECT DISTINCT table_schema AS s, table_name AS t FROM information_schema.columns
151
+ WHERE column_name = ANY($1) AND table_schema NOT IN ('pg_catalog','information_schema') AND table_schema NOT LIKE 'pg_%'`,
152
+ [ORG_COLUMNS]
153
+ )
154
+ ).rows
155
+ const fks = (
156
+ await src.query(`SELECT ns.nspname AS cs, cl.relname AS ct, fns.nspname AS ps, fcl.relname AS pt
157
+ FROM pg_constraint con
158
+ JOIN pg_class cl ON cl.oid=con.conrelid JOIN pg_namespace ns ON ns.oid=cl.relnamespace
159
+ JOIN pg_class fcl ON fcl.oid=con.confrelid JOIN pg_namespace fns ON fns.oid=fcl.relnamespace
160
+ WHERE con.contype='f'`)
161
+ ).rows
162
+ const all = (
163
+ await src.query(`SELECT ns.nspname AS s, cl.relname AS t
164
+ FROM pg_class cl JOIN pg_namespace ns ON ns.oid=cl.relnamespace
165
+ WHERE cl.relkind='r' AND ns.nspname NOT IN ('pg_catalog','information_schema') AND ns.nspname NOT LIKE 'pg_%'`)
166
+ ).rows
167
+ const key = (s, t) => `${s}.${t}`
168
+ const orgScoped = new Set(orgColRows.map((r) => key(r.s, r.t)))
169
+ let grew = true
170
+ while (grew) {
171
+ grew = false
172
+ for (const e of fks) {
173
+ const child = key(e.cs, e.ct)
174
+ if (!orgScoped.has(child) && orgScoped.has(key(e.ps, e.pt))) {
175
+ orgScoped.add(child)
176
+ grew = true
177
+ }
178
+ }
179
+ }
180
+ const excluded = (s, t) =>
181
+ orgScoped.has(key(s, t)) ||
182
+ isHistory(s, t) ||
183
+ BASE_EXCLUDE_TABLES.has(key(s, t)) ||
184
+ BASE_EXCLUDE_PATTERNS.some((re) => re.test(key(s, t)))
185
+ return all.filter((r) => !excluded(r.s, r.t)).map((r) => ({ schema: r.s, table: r.t }))
186
+ }
187
+
188
+ // stream one table in full, source → scratch, via server-side COPY (constant memory).
189
+ async function copyTableFull(src, scratch, schema, table) {
190
+ const rd = src.query(copyTo(`COPY ${qTable(schema, table)} TO STDOUT`))
191
+ const wr = scratch.query(copyFrom(`COPY ${qTable(schema, table)} FROM STDIN`))
192
+ await pipeline(rd, wr)
193
+ }
194
+
195
+ // build() runs IN-CLUSTER against its OWN scratch instance (never the dooer-database volume). It requires a
196
+ // real cluster + bucket to run; computeIncludeSet is the pure, unit-testable core. `shredFn(client)` must
197
+ // apply the shred rules and throw if the PII audit finds uncovered columns.
198
+ async function build(opts) {
199
+ const {
200
+ source, // { host, user, password, database:'dooer' } — the live in-cluster DB (read-only here)
201
+ scratch, // { host, user, password } — a SEPARATE scratch postgres (NOT dooer-database); own volume
202
+ scratchDatabase = 'dooer_base',
203
+ artifactOut = path.join(os.tmpdir(), `dooer-base-db-${Date.now()}.dump`),
204
+ shredFn,
205
+ upload, // async (file) => void — pushes the artifact to the OBC bucket
206
+ execute = false,
207
+ } = opts
208
+ if (!source || !source.host) throw new Error('build() needs source DB connection info')
209
+ if (source.database !== 'dooer') throw new Error('refusing: source.database must be the live `dooer`')
210
+ const scr = scratch || source // in a Job the scratch is a sidecar; must differ from dooer-database in prod
211
+ if (execute && scr.host === source.host && scr.database !== 'dooer') {
212
+ // allowed only if it's clearly a separate DB on a separate host; refuse same-host to protect staging
213
+ throw new Error(
214
+ 'scratch must be a SEPARATE postgres instance (not the dooer-database host) — see the CronJob sidecar'
215
+ )
216
+ }
217
+ console.log(
218
+ `\n=== db build (in-cluster, no organizations) ===\nsource: ${source.host}/dooer · scratch: ${scr.host}/${scratchDatabase}`
219
+ )
220
+
221
+ const srcRo = await connectDirect(source)
222
+ const include = await computeIncludeSet(srcRo)
223
+ console.log(`include set: ${include.length} non-org tables (full copy) + users`)
224
+ if (!execute) {
225
+ console.log(
226
+ 'DRY-RUN: would schema-restore, full-copy the include set, shred, audit, dump, upload. Pass --execute (in-cluster).'
227
+ )
228
+ await srcRo.end()
229
+ return { include: include.length }
230
+ }
231
+ try {
232
+ // 1. schema-only → scratch DB (create it fresh on the scratch instance)
233
+ const admin = await connectDirect({ ...scr, database: 'postgres', ssl: false })
234
+ // terminate any lingering connections (e.g. from a previously-killed run) so DROP can proceed
235
+ await admin.query(
236
+ `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=$1 AND pid<>pg_backend_pid()`,
237
+ [scratchDatabase]
238
+ )
239
+ await admin.query(`DROP DATABASE IF EXISTS ${qIdent(scratchDatabase)}`)
240
+ await admin.query(`CREATE DATABASE ${qIdent(scratchDatabase)} TEMPLATE template0`)
241
+ await admin.end()
242
+ const schemaFile = `${artifactOut}.schema.sql`
243
+ // --no-privileges/--no-owner: the base doesn't need staging's ACLs/roles (a 537k-line dump of
244
+ // per-table GRANTs to dooer_reader/dooer_writer that don't exist in scratch); skip them.
245
+ pg('pg_dump', ['--schema-only', '--no-owner', '--no-privileges', '-d', dsn(source), '-f', schemaFile])
246
+ pg('psql', ['-v', 'ON_ERROR_STOP=0', '-d', dsn({ ...scr, database: scratchDatabase }), '-f', schemaFile])
247
+ fs.rmSync(schemaFile, { force: true })
248
+ // 2. full-copy the include set (FK/triggers disabled during load)
249
+ const tgt = await connectDirect({ ...scr, database: scratchDatabase, ssl: false })
250
+ try {
251
+ await tgt.query("SET session_replication_role = 'replica'")
252
+ let i = 0
253
+ for (const { schema, table } of include) {
254
+ i++
255
+ process.stderr.write(`\r[copy ${i}/${include.length}] ${schema}.${table}`.slice(0, 70).padEnd(70))
256
+ try {
257
+ await copyTableFull(srcRo, tgt, schema, table)
258
+ } catch (e) {
259
+ console.error(`\n skip ${schema}.${table}: ${e.message}`)
260
+ }
261
+ }
262
+ process.stderr.write('\n')
263
+ await tgt.query("SET session_replication_role = 'origin'")
264
+ // 3. shred + audit
265
+ if (typeof shredFn !== 'function') throw new Error('build() requires a shredFn (shred + PII audit)')
266
+ await shredFn(tgt)
267
+ } finally {
268
+ await tgt.end()
269
+ }
270
+ // 4. dump artifact, 5. upload, 6. drop scratch
271
+ pg('pg_dump', ['-Fc', '-d', dsn({ ...scr, database: scratchDatabase }), '-f', artifactOut])
272
+ if (typeof upload === 'function') await upload(artifactOut)
273
+ const admin2 = await connectDirect({ ...scr, database: 'postgres', ssl: false })
274
+ await admin2.query(
275
+ `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=$1 AND pid<>pg_backend_pid()`,
276
+ [scratchDatabase]
277
+ )
278
+ await admin2.query(`DROP DATABASE IF EXISTS ${qIdent(scratchDatabase)}`)
279
+ await admin2.end()
280
+ console.log(`\ndb build complete — artifact: ${artifactOut}`)
281
+ } finally {
282
+ await srcRo.end()
283
+ }
284
+ }
285
+
286
+ // pull() restores the newest base artifact into the LOCAL Postgres. Download is injected so the command
287
+ // layer owns the OBC/kubectl plumbing and this stays testable.
288
+ async function pull(opts) {
289
+ const {
290
+ download,
291
+ local = { host: 'localhost', port: 5432, user: 'dooer', password: 'dooer', database: 'dooer' },
292
+ execute = false,
293
+ } = opts
294
+ if (local.host !== 'localhost' && local.host !== '127.0.0.1')
295
+ throw new Error('db pull only restores into the LOCAL Postgres')
296
+ if (!execute) {
297
+ console.log('DRY-RUN: would download the newest base artifact and restore into local Postgres. Pass --execute.')
298
+ return
299
+ }
300
+ if (typeof download !== 'function')
301
+ throw new Error('pull() requires a download() that returns the artifact file path')
302
+ const file = await download()
303
+ console.log(`restoring ${file} into ${local.host}/${local.database} …`)
304
+ // pg_restore exits non-zero on ANY error, but the base has expected, ignorable ones: `public` already
305
+ // exists, and FK constraints referencing excluded tables (e.g. workflow.trigger → the excluded event
306
+ // table). Data + schema still restore — so we run it non-fatally and just report the ignored count.
307
+ const r = spawnSync('pg_restore', ['--no-owner', '--clean', '--if-exists', '-d', dsn(local), file], {
308
+ stdio: 'inherit',
309
+ })
310
+ if (r.status !== 0)
311
+ console.log(`(pg_restore reported ignorable errors — expected: 'public' exists + FKs into excluded tables)`)
312
+ console.log('db pull complete.')
313
+ }
314
+
315
+ module.exports = {
316
+ build,
317
+ pull,
318
+ computeIncludeSet,
319
+ bulkPurge,
320
+ customerOrgIds,
321
+ filterSetSql,
322
+ DEFAULT_PURGE_PARTNERS,
323
+ BASE_EXCLUDE_TABLES,
324
+ ORG_COLUMNS,
325
+ }