@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
package/bin/index.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ require('../lib/cli')().catch((e) => {
4
+ // eslint-disable-next-line no-console
5
+ console.error(`\nERROR: ${e.message}\n`)
6
+ process.exit(1)
7
+ })
@@ -0,0 +1,18 @@
1
+ FROM node:18-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Built-ins only — no `npm install` needed.
6
+ COPY package.json ./
7
+ COPY server.js ./
8
+
9
+ # Registry is seeded from / persisted to REGISTRY_FILE (default /registry/registry.json). Mount a
10
+ # volume at /registry so CLI-set overrides survive a container restart.
11
+ ENV PORT=8500 \
12
+ REGISTRY_FILE=/registry/registry.json \
13
+ DEFAULT_TARGET_PORT=3000 \
14
+ DEFAULT_ADDRESS_TEMPLATE="<name>"
15
+
16
+ EXPOSE 8500
17
+
18
+ CMD ["node", "server.js"]
@@ -0,0 +1,99 @@
1
+ # discovery-router
2
+
3
+ A tiny, mutable **Consul-health-API** server for the `dooer-test-env` local stack. It is the routing
4
+ brain of the local environment (ENVIRONMENT-PLAN.md §6): it answers the one endpoint `@dooer/service`
5
+ calls to resolve peers, backed by an in-memory registry the CLI updates as services start, stop, switch
6
+ to local code, or change version — so peers pick up the new location **without restarting**.
7
+
8
+ It replaces the static `dummy-consul-service` stub. No framework, no dependencies (Node 18 built-ins only).
9
+
10
+ ## Why it looks like Consul
11
+
12
+ `@dooer/service/lib/get-service.js` resolves a service by fetching
13
+ `http://$CONSUL_HTTP_ADDR/v1/health/service/<name>`, then filtering to nodes whose `Checks` are **all**
14
+ `Status === 'passing'` and reading `Service.Address` + `Service.Port` (and `Service.Tags` for an optional
15
+ `version:` tag). Every service in the stack points `CONSUL_HTTP_ADDR` at this router, so **no service code
16
+ changes** are needed.
17
+
18
+ ### Response shape (`GET /v1/health/service/:name`)
19
+
20
+ A JSON array with exactly one healthy node:
21
+
22
+ ```json
23
+ [
24
+ {
25
+ "Node": { "Node": "discovery-router", "Address": "service-ledger" },
26
+ "Service": { "ID": "service-ledger", "Service": "service-ledger", "Address": "service-ledger", "Port": 3000, "Tags": [] },
27
+ "Checks": [{ "Status": "passing", "ServiceName": "service-ledger", "CheckID": "service:service-ledger" }]
28
+ }
29
+ ]
30
+ ```
31
+
32
+ `get-service.js` reads `Service.Address` + `Service.Port` → host `service-ledger:3000`.
33
+
34
+ ## Resolution rules
35
+
36
+ For a requested name the router returns:
37
+
38
+ 1. **An explicit override** if one is registered (`PUT /registry/:name`) — the service's current real
39
+ location (a compose container, a chosen published version, or `host.docker.internal:<devport>` when
40
+ run as local code).
41
+ 2. **Otherwise the default template** — `DEFAULT_ADDRESS_TEMPLATE` with `<name>` substituted, on
42
+ `DEFAULT_TARGET_PORT`. With the defaults that is **`<name>:3000`**, i.e. the service's own compose
43
+ container DNS name.
44
+
45
+ ### Unknown-name fallback decision
46
+
47
+ **Unknown names fall back to `<name>:DEFAULT_TARGET_PORT` (compose DNS), they do NOT return `[]`.** This
48
+ is the recommended behaviour from the plan: the stack works out of the box (every compose service is
49
+ reachable by its container name with zero registry config), and explicit overrides win when the CLI
50
+ narrows a name to local code or a specific version. To get "unknown → unresolvable" instead, set
51
+ `DEFAULT_ADDRESS_TEMPLATE` to an address that does not resolve; the router itself always returns a single
52
+ node so the caller fails at connect time rather than at discovery time.
53
+
54
+ ## API
55
+
56
+ | Method | Path | Body | Purpose |
57
+ |---|---|---|---|
58
+ | `GET` | `/v1/health/service/:name` | — | Consul-shaped array (one passing node) for the effective target. |
59
+ | `GET` | `/registry` | — | `{ overrides, effective, defaults }` — current explicit overrides, their resolved targets, and the default config. |
60
+ | `PUT` | `/registry/:name` | `{ "address": "host", "port": 1234 }` | Set/replace an override. Persisted to `REGISTRY_FILE`. |
61
+ | `DELETE` | `/registry/:name` | — | Remove an override (revert this name to the default template). Persisted. |
62
+
63
+ Overrides are persisted to `REGISTRY_FILE` on every change, so they survive a restart.
64
+
65
+ ## Config (env)
66
+
67
+ | Var | Default | Meaning |
68
+ |---|---|---|
69
+ | `PORT` | `8500` | Listen port (Consul's default, so `CONSUL_HTTP_ADDR=discovery-router:8500`). |
70
+ | `REGISTRY_FILE` | `/registry/registry.json` | Seed on start + persistence target for overrides. |
71
+ | `DEFAULT_TARGET_PORT` | `3000` | Port used by the default template (services boot on `DOOER_MICROSERVICE_PORT`=3000). |
72
+ | `DEFAULT_ADDRESS_TEMPLATE` | `<name>` | Address for names without an override; `<name>` → the requested service name. |
73
+
74
+ ## How the CLI / compose use it
75
+
76
+ - **compose:** one `discovery-router` service; every other service gets `CONSUL_HTTP_ADDR=discovery-router:8500`.
77
+ A named volume mounted at `/registry` persists overrides.
78
+ - **CLI:** `up` seeds the registry from the running compose services; `service local` / `unlocal` /
79
+ `deploy` / `stop` call `PUT` / `DELETE /registry/:name` to repoint one name. Use `lib/discovery/client.js`
80
+ (`setTarget` / `clearTarget` / `list`).
81
+
82
+ ## Registry file format
83
+
84
+ `name -> { address, port }` (see `registry.example.json`):
85
+
86
+ ```json
87
+ {
88
+ "service-ai-agents": { "address": "host.docker.internal", "port": 4021 },
89
+ "service-ledger": { "address": "service-ledger", "port": 3000 }
90
+ }
91
+ ```
92
+
93
+ ## Run locally
94
+
95
+ ```bash
96
+ PORT=8500 REGISTRY_FILE=./registry.json node server.js
97
+ curl localhost:8500/v1/health/service/service-ledger
98
+ curl -X PUT localhost:8500/registry/service-ai-agents -d '{"address":"host.docker.internal","port":4021}'
99
+ ```
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@dooer/dooer-test-env-discovery-router",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "Mutable local Consul-health-API router for the dooer-test-env local stack (name -> endpoint).",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "discovery-router": "server.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node server.js"
12
+ }
13
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "service-ai-agents": { "address": "host.docker.internal", "port": 4021 },
3
+ "service-ledger": { "address": "service-ledger", "port": 3000 },
4
+ "service-graphql": { "address": "service-graphql", "port": 3000 }
5
+ }
@@ -0,0 +1,272 @@
1
+ const http = require('http')
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const crypto = require('crypto')
5
+
6
+ // --- Config (all from env, with compose-friendly defaults) ---------------------------------------
7
+ const PORT = Number(process.env.PORT || 8500)
8
+ const REGISTRY_FILE = process.env.REGISTRY_FILE || '/registry/registry.json'
9
+ const DEFAULT_TARGET_PORT = Number(process.env.DEFAULT_TARGET_PORT || 3000)
10
+ // `<name>` is substituted with the requested service name. Default `<name>` => the compose container's
11
+ // DNS name, so an unknown-but-expected service resolves to `<name>:DEFAULT_TARGET_PORT` out of the box.
12
+ const DEFAULT_ADDRESS_TEMPLATE = process.env.DEFAULT_ADDRESS_TEMPLATE || '<name>'
13
+
14
+ // --- Registry (mutable, name -> { address, port }) -----------------------------------------------
15
+ // Entries loaded from REGISTRY_FILE are the "overrides": explicit targets that win over the default.
16
+ // Everything not listed falls back to the default template below (compose DNS), so the router works
17
+ // with zero config and the CLI narrows it down as services start / go local / switch version.
18
+ let registry = {}
19
+
20
+ function loadRegistry() {
21
+ try {
22
+ const raw = fs.readFileSync(REGISTRY_FILE, 'utf8')
23
+ const parsed = JSON.parse(raw)
24
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
25
+ registry = parsed
26
+ }
27
+ log('registry-loaded', { file: REGISTRY_FILE, entries: Object.keys(registry).length })
28
+ } catch (error) {
29
+ if (error.code === 'ENOENT') {
30
+ log('registry-file-absent', { file: REGISTRY_FILE })
31
+ } else {
32
+ log('registry-load-failed', { file: REGISTRY_FILE, error: error.message })
33
+ }
34
+ }
35
+ }
36
+
37
+ function persistRegistry() {
38
+ try {
39
+ fs.mkdirSync(path.dirname(REGISTRY_FILE), { recursive: true })
40
+ fs.writeFileSync(REGISTRY_FILE, `${JSON.stringify(registry, null, 2)}\n`)
41
+ } catch (error) {
42
+ log('registry-persist-failed', { file: REGISTRY_FILE, error: error.message })
43
+ }
44
+ }
45
+
46
+ // The effective target for a name: an explicit override if present, else the default template.
47
+ function resolveTarget(name) {
48
+ const override = registry[name]
49
+ if (override && override.address) {
50
+ return { address: override.address, port: Number(override.port) || DEFAULT_TARGET_PORT, source: 'override' }
51
+ }
52
+ return {
53
+ address: DEFAULT_ADDRESS_TEMPLATE.replace(/<name>/g, name),
54
+ port: DEFAULT_TARGET_PORT,
55
+ source: 'default',
56
+ }
57
+ }
58
+
59
+ // The Consul health-API node shape @dooer/service/lib/get-service.js parses: it filters nodes whose
60
+ // Checks are all Status==='passing', then reads Service.Address + Service.Port (and Service.Tags for
61
+ // the optional version tag). We always return exactly one passing node.
62
+ function consulNode(name, { address, port }) {
63
+ return {
64
+ Node: { Node: 'discovery-router', Address: address },
65
+ Service: {
66
+ ID: name,
67
+ Service: name,
68
+ Address: address,
69
+ Port: port,
70
+ Tags: [],
71
+ },
72
+ Checks: [{ Status: 'passing', ServiceName: name, CheckID: `service:${name}` }],
73
+ }
74
+ }
75
+
76
+ // --- Minimal Consul session + KV (single-node leader election) -----------------------------------
77
+ // doode's leader election uses the Consul session + KV lock API (PUT /v1/session/create, then KV
78
+ // acquire). Locally there is exactly ONE instance of each service, so it is always the rightful leader:
79
+ // we grant every session and every lock acquire. This is the difference between a static Consul stub and
80
+ // a working local env (without it, all scheduled/background handlers stay dormant).
81
+ const sessions = new Set()
82
+ const kv = new Map() // key -> { value, session }
83
+
84
+ function readRaw(req) {
85
+ return new Promise((resolve, reject) => {
86
+ const chunks = []
87
+ req.on('data', (c) => chunks.push(c))
88
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
89
+ req.on('error', reject)
90
+ })
91
+ }
92
+
93
+ // returns true if the path was a session/KV request (already responded), false otherwise
94
+ async function handleConsulLock(req, res, url) {
95
+ const { pathname, searchParams } = url
96
+ const { method } = req
97
+
98
+ if (method === 'PUT' && pathname === '/v1/session/create') {
99
+ const id = crypto.randomUUID()
100
+ sessions.add(id)
101
+ sendJson(res, 200, { ID: id })
102
+ return true
103
+ }
104
+ let m = pathname.match(/^\/v1\/session\/(renew|destroy)\/(.+)$/)
105
+ if (method === 'PUT' && m) {
106
+ const id = decodeURIComponent(m[2])
107
+ if (m[1] === 'destroy') {
108
+ sessions.delete(id)
109
+ sendJson(res, 200, true)
110
+ } else {
111
+ sendJson(res, 200, [{ ID: id, TTL: '15s', LockDelay: 0, Behavior: 'release' }])
112
+ }
113
+ return true
114
+ }
115
+ m = pathname.match(/^\/v1\/kv\/(.+)$/)
116
+ if (m) {
117
+ const key = decodeURIComponent(m[1])
118
+ if (method === 'PUT') {
119
+ const value = await readRaw(req)
120
+ const acquire = searchParams.get('acquire')
121
+ const release = searchParams.get('release')
122
+ if (release) {
123
+ const cur = kv.get(key)
124
+ if (cur && cur.session === release) kv.set(key, { value: cur.value, session: null })
125
+ sendJson(res, 200, true)
126
+ } else {
127
+ // single-node: always grant the lock (or plain write)
128
+ kv.set(key, { value, session: acquire || null })
129
+ sendJson(res, 200, true)
130
+ }
131
+ return true
132
+ }
133
+ if (method === 'GET') {
134
+ const cur = kv.get(key)
135
+ if (!cur) {
136
+ sendJson(res, 404, [])
137
+ return true
138
+ }
139
+ sendJson(res, 200, [
140
+ {
141
+ Key: key,
142
+ Value: Buffer.from(cur.value || '').toString('base64'),
143
+ Session: cur.session || undefined,
144
+ CreateIndex: 1,
145
+ ModifyIndex: 1,
146
+ LockIndex: 1,
147
+ Flags: 0,
148
+ },
149
+ ])
150
+ return true
151
+ }
152
+ if (method === 'DELETE') {
153
+ kv.delete(key)
154
+ sendJson(res, 200, true)
155
+ return true
156
+ }
157
+ }
158
+ return false
159
+ }
160
+
161
+ // --- HTTP helpers --------------------------------------------------------------------------------
162
+ function log(event, data) {
163
+ // eslint-disable-next-line no-console
164
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event, ...data }))
165
+ }
166
+
167
+ function sendJson(res, statusCode, body) {
168
+ const payload = JSON.stringify(body)
169
+ res.writeHead(statusCode, { 'content-type': 'application/json' })
170
+ res.end(payload)
171
+ }
172
+
173
+ function readBody(req) {
174
+ return new Promise((resolve, reject) => {
175
+ const chunks = []
176
+ req.on('data', (chunk) => chunks.push(chunk))
177
+ req.on('end', () => {
178
+ const raw = Buffer.concat(chunks).toString('utf8')
179
+ if (!raw) return resolve({})
180
+ try {
181
+ return resolve(JSON.parse(raw))
182
+ } catch (error) {
183
+ return reject(new Error(`invalid JSON body: ${error.message}`))
184
+ }
185
+ })
186
+ req.on('error', reject)
187
+ })
188
+ }
189
+
190
+ // --- Router --------------------------------------------------------------------------------------
191
+ async function handle(req, res) {
192
+ const url = new URL(req.url, `http://localhost:${PORT}`)
193
+ const { pathname } = url
194
+ const { method } = req
195
+
196
+ // Consul health API — what @dooer/service calls on every resolve.
197
+ const healthMatch = pathname.match(/^\/v1\/health\/service\/(.+)$/)
198
+ if (method === 'GET' && healthMatch) {
199
+ const name = decodeURIComponent(healthMatch[1])
200
+ const target = resolveTarget(name)
201
+ // Always resolvable: unknown names fall back to the compose DNS default (see resolveTarget).
202
+ return sendJson(res, 200, [consulNode(name, target)])
203
+ }
204
+
205
+ // Consul session + KV (leader election). Returns true if it handled the request.
206
+ if (pathname.startsWith('/v1/session/') || pathname.startsWith('/v1/kv/')) {
207
+ if (await handleConsulLock(req, res, url)) return undefined
208
+ }
209
+
210
+ // Admin: list the whole registry (explicit overrides + config that drives the defaults).
211
+ if (method === 'GET' && pathname === '/registry') {
212
+ const effective = {}
213
+ for (const name of Object.keys(registry)) {
214
+ effective[name] = resolveTarget(name)
215
+ }
216
+ return sendJson(res, 200, {
217
+ overrides: registry,
218
+ effective,
219
+ defaults: { addressTemplate: DEFAULT_ADDRESS_TEMPLATE, port: DEFAULT_TARGET_PORT },
220
+ })
221
+ }
222
+
223
+ const nameMatch = pathname.match(/^\/registry\/(.+)$/)
224
+ if (nameMatch) {
225
+ const name = decodeURIComponent(nameMatch[1])
226
+
227
+ // Admin: set/replace an override for one service.
228
+ if (method === 'PUT') {
229
+ let body
230
+ try {
231
+ body = await readBody(req)
232
+ } catch (error) {
233
+ return sendJson(res, 400, { error: error.message })
234
+ }
235
+ if (!body || typeof body.address !== 'string' || !body.address) {
236
+ return sendJson(res, 400, { error: 'body must be { address: string, port: number }' })
237
+ }
238
+ const port = Number(body.port) || DEFAULT_TARGET_PORT
239
+ registry[name] = { address: body.address, port }
240
+ persistRegistry()
241
+ log('override-set', { name, address: body.address, port })
242
+ return sendJson(res, 200, { name, ...registry[name] })
243
+ }
244
+
245
+ // Admin: remove an override (revert this name to the default template).
246
+ if (method === 'DELETE') {
247
+ const existed = Object.prototype.hasOwnProperty.call(registry, name)
248
+ delete registry[name]
249
+ persistRegistry()
250
+ log('override-cleared', { name, existed })
251
+ return sendJson(res, 200, { name, cleared: existed, effective: resolveTarget(name) })
252
+ }
253
+ }
254
+
255
+ return sendJson(res, 404, { error: 'not found' })
256
+ }
257
+
258
+ const server = http.createServer((req, res) => {
259
+ const start = Date.now()
260
+ res.on('finish', () =>
261
+ log('request', { method: req.method, url: req.url, status: res.statusCode, ms: Date.now() - start })
262
+ )
263
+ handle(req, res).catch((error) => {
264
+ log('handler-error', { url: req.url, error: error.message })
265
+ if (!res.headersSent) sendJson(res, 500, { error: 'internal error' })
266
+ })
267
+ })
268
+
269
+ loadRegistry()
270
+ server.listen(PORT, () => log('listening', { port: PORT, registryFile: REGISTRY_FILE }))
271
+
272
+ module.exports = { server, resolveTarget, consulNode }
package/lib/account.js ADDED
@@ -0,0 +1,120 @@
1
+ // Provision an empty-but-functional test account in the LOCAL DB — the `customer new` command.
2
+ //
3
+ // Mirrors what service-accounts' create-organization + subscriptions.post do, but as direct inserts into
4
+ // the local Postgres (self-contained: no service token / network). Creates exactly what's needed to use the
5
+ // account and nothing else:
6
+ // - service_accounts.companies the organization (SE / AB, minimal required columns)
7
+ // - service_accounts.companies2users the given user linked as role 'Owner'
8
+ // - service_accounts.subscriptions the same active set Ghost Inspector has on staging
9
+ // - service_accounts."partnerOrganization" the org placed under partner `dooer` by default
10
+ // It does NOT add customer data (ledger, documents, tasks, …) — the account starts empty.
11
+
12
+ const crypto = require('crypto')
13
+ const { Client } = require('pg')
14
+
15
+ // Ghost Inspector's active subscription set on staging (Jimmy 2026-09-01).
16
+ const SUBSCRIPTION_TYPES = ['accounting', 'billing', 'salaries', 'sales', 'prebook']
17
+
18
+ // The partner every new test account is placed under by default (Jimmy 2026-09-01).
19
+ const DEFAULT_PARTNER_DOMAIN = 'dooer'
20
+
21
+ const uuid = () => crypto.randomUUID()
22
+ // A plausible unique 10-digit Swedish org number (556XXXXXXX) — dev placeholder, not a real registration.
23
+ const orgNumber = () => `556${String(crypto.randomInt(0, 1e7)).padStart(7, '0')}`
24
+
25
+ // short_name must match ^[a-z0-9][a-z0-9-]+[a-z0-9]$ (service-accounts models/company.js) — the product
26
+ // slugifies the name for it. Mirror that: lowercase, non-alnum → dashes, trim leading/trailing dashes.
27
+ function slugify(name) {
28
+ let s = String(name)
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9]+/g, '-')
31
+ .replace(/^-+|-+$/g, '')
32
+ if (s.length < 3) s = `${s || 'org'}-${crypto.randomBytes(2).toString('hex')}`.replace(/^-+/, '')
33
+ return s
34
+ }
35
+ // Find a slug not already taken (companies.short_name), appending -N like the product's getSlug does.
36
+ async function uniqueSlug(client, name) {
37
+ const base = slugify(name)
38
+ for (let i = 0; i < 50; i++) {
39
+ const candidate = i === 0 ? base : `${base}-${i + 1}`
40
+ const hit = await client.query('SELECT 1 FROM service_accounts.companies WHERE short_name=$1', [candidate])
41
+ if (!hit.rowCount) return candidate
42
+ }
43
+ return `${base}-${crypto.randomBytes(3).toString('hex')}`
44
+ }
45
+
46
+ async function createAccount({ local, name, ownerUserId, execute = false } = {}) {
47
+ if (!name) throw new Error('customer new: a name is required')
48
+ if (!ownerUserId) throw new Error('customer new: --owner <userId> is required')
49
+
50
+ const client = new Client({ ...local, ssl: local.ssl === false ? undefined : { rejectUnauthorized: false } })
51
+ await client.connect()
52
+ try {
53
+ const owner = await client.query('SELECT users_pk, email FROM service_accounts.users WHERE users_pk=$1', [
54
+ ownerUserId,
55
+ ])
56
+ if (!owner.rowCount) throw new Error(`owner user ${ownerUserId} not found in the local DB (run \`db pull\` first?)`)
57
+
58
+ const partner = await client.query('SELECT id, name FROM service_accounts.partner WHERE domain=$1', [
59
+ DEFAULT_PARTNER_DOMAIN,
60
+ ])
61
+ if (!partner.rowCount) throw new Error(`partner '${DEFAULT_PARTNER_DOMAIN}' not found in the local DB`)
62
+
63
+ const orgId = uuid()
64
+ const shortName = await uniqueSlug(client, name)
65
+ const plan = {
66
+ orgId,
67
+ name,
68
+ shortName,
69
+ owner: owner.rows[0].email,
70
+ partner: DEFAULT_PARTNER_DOMAIN,
71
+ subscriptions: SUBSCRIPTION_TYPES,
72
+ }
73
+ if (!execute) {
74
+ console.log('DRY-RUN (pass --execute to create):')
75
+ console.log(JSON.stringify(plan, null, 2))
76
+ return plan
77
+ }
78
+
79
+ await client.query('BEGIN')
80
+ await client.query(
81
+ `INSERT INTO service_accounts.companies
82
+ (companies_pk, fk_countries_pk, fk_company_types_pk, company_name, short_name, organization_number,
83
+ api_uuid, language, "timeZone", currency, inserted_at, updated_at)
84
+ VALUES ($1,'SE','AB',$2,$3,$4,$5,'sv','Europe/Stockholm','SEK',now(),now())`,
85
+ [orgId, name, shortName, orgNumber(), uuid()]
86
+ )
87
+ await client.query(
88
+ `INSERT INTO service_accounts.companies2users
89
+ (fk_companies_pk, fk_users_pk, fk_user_roles_at_companies_pk, api_uuid)
90
+ VALUES ($1,$2,'Owner',$3)`,
91
+ [orgId, ownerUserId, uuid()]
92
+ )
93
+ for (const type of SUBSCRIPTION_TYPES) {
94
+ await client.query(
95
+ `INSERT INTO service_accounts.subscriptions
96
+ (subscriptions_pk, fk_companies_pk, subscription_type, status, start_date, api_uuid, inserted_at, updated_at)
97
+ VALUES ($1,$2,$3,'active',now(),$4,now(),now())`,
98
+ [uuid(), orgId, type, uuid()]
99
+ )
100
+ }
101
+ await client.query(
102
+ `INSERT INTO service_accounts."partnerOrganization" (id, "partnerId", "organizationId", "createdAt", "updatedAt")
103
+ VALUES ($1,$2,$3,now(),now())`,
104
+ [uuid(), partner.rows[0].id, orgId]
105
+ )
106
+ await client.query('COMMIT')
107
+ return plan
108
+ } catch (e) {
109
+ try {
110
+ await client.query('ROLLBACK')
111
+ } catch (_) {
112
+ /* ignore */
113
+ }
114
+ throw e
115
+ } finally {
116
+ await client.end()
117
+ }
118
+ }
119
+
120
+ module.exports = { createAccount, SUBSCRIPTION_TYPES }
@@ -0,0 +1,12 @@
1
+ // The @dooer LOCAL dev auth keypair — the `local` authPublicKey / authPrivateKey shipped (in the clear) in
2
+ // every service's config/configuration.json. NOT a secret: it exists so a dev stack can sign+verify tokens
3
+ // among itself. We force it across the local env because service-accounts (the only signer) falls back to
4
+ // this local private key, while the staging manifests would otherwise make every verifier trust the STAGING
5
+ // public key instead — the "invalid signature" mismatch. authIssuer is "https://example.com/" everywhere, so
6
+ // it needs no override. Values are base64-encoded PEM, exactly as @dooer/config expects them.
7
+ module.exports = {
8
+ AUTH_PUBLIC_KEY_B64:
9
+ 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFyVmZYU1hXVWgvRndZSDcrbGorUwpZeFFHYXFvVENLQTNKN25TanExZUNodG9OcGJOYTA3Q251U055OFpQTWRDWE15ZDhVN2phUk1acFFYT0NHYUwrCkNpM2F1TVlOSVk0NXd5MG1RYXREYUgyZkY0OGE2NTV3MWJHMlZGVlFFVzkvaEJsUGkzRnA2dmN1eWRaYkMyVWsKOHFHdU43dG9FYlZyYWlsWlMxUGliMVl2SzNKNlZ4VnlRRmdXVVkyV1FHNjZIcFp4NEduL3JKdEpEdE1CMFd5aAozOWdVYTREMGtldUhWR1k2ZzRIR2NueHgycjB2ZzBQRlZtQkFsbThPclVhd0YrcDFLRG1HTDVDNnpTZTBkUDkxCjVKVzNUWFJ5ZGthcWErQzZBUzJhazlYUXpQMzZaazNhQ3duOS9ZYW5QS0xqOEZ2T3NVMTU5MzBvL1Npcm1BMTgKZndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==',
10
+ AUTH_PRIVATE_KEY_B64:
11
+ 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb2dJQkFBS0NBUUVBclZmWFNYV1VoL0Z3WUg3K2xqK1NZeFFHYXFvVENLQTNKN25TanExZUNodG9OcGJOCmEwN0NudVNOeThaUE1kQ1hNeWQ4VTdqYVJNWnBRWE9DR2FMK0NpM2F1TVlOSVk0NXd5MG1RYXREYUgyZkY0OGEKNjU1dzFiRzJWRlZRRVc5L2hCbFBpM0ZwNnZjdXlkWmJDMlVrOHFHdU43dG9FYlZyYWlsWlMxUGliMVl2SzNKNgpWeFZ5UUZnV1VZMldRRzY2SHBaeDRHbi9ySnRKRHRNQjBXeWgzOWdVYTREMGtldUhWR1k2ZzRIR2NueHgycjB2CmcwUEZWbUJBbG04T3JVYXdGK3AxS0RtR0w1QzZ6U2UwZFA5MTVKVzNUWFJ5ZGthcWErQzZBUzJhazlYUXpQMzYKWmszYUN3bjkvWWFuUEtMajhGdk9zVTE1OTMwby9TaXJtQTE4ZndJREFRQUJBb0lCQUd4bFg3VUtGK1dpcjFsSQpVTDkzNVh5Yml4K3NWdGF3Q1kySUFBbEVsR2ZSbDZ0N1JkMXlsUFZ6aXRBQXBJbE1IU0RaOGo4UWV6MUNyWk5HClBmYjJHOUlWdG82Ukdxai9IVlBWcWZTcXVpNWZUOXNWbkFuM1BDd0ZRelVkWEVKcTdOeVlUMHV0a2tSaWFobkIKTjVHNm9BNHgySlgxVlRDdHVBdmM2My9WWnVXamdiVnBCbTRsWXJRekNyalUvOEJzRVIvNXFOVDUvNGNtNHlFNgpyY2JjNHNpUlY5NnZtc040QnNqeWtoczZPTCtoNjFLSWs0TSsyK3JscXZwdGQ0ZjNrNDhRK3M2MGw4eWR0RldUClZ1alM2S1JDbTMyWnFhSlRDcHZxTG16bW5EcTM4RlRDdWF3N0dhMzNPTmNaanBEUnpmWTJ3cUQ3T21sMXZmNnUKbUwvYXlHRUNnWUVBMStHU3RwSXhTcjNLN05TOWhScUVQUnFuMTJLYkRoMnRYcmFZSGZ5SExxWGhKWm9adVZnbQpmdWIzMnNZSndtcGZibGp1SDlpREp4TW9xSWUxSjdxVjNmVkU0OVFyNmtCRGd4M21uWElJSDhjMzFrNG9teFpzCm5BZXd4NFNnSFg2Vldualc1RitkL3Y2U1loS0VKT204MzBTVmdMSXU1WGlmRmlBdHBTY2JTTmtDZ1lFQXpZNkwKakdkdUdaVWhhZ2RYRU5vVk5LcWMxSEdmU29EYi9GdjNlMzJUbUtXeGhIWmhZMDlYTSt4S3d2TlpycVFXZjZ6UAp0ZWRoU1lKMTloVFlXVHBJWjZhbzlFUEF6MG1CbThLbFdITk9sSkxvaEx1bWYxV1QvTzZvY1YvR01FZEpVRHQ0Cnloa3llVHRwVDh0Y29CRm9CL0d4NzNMdTRteW11MWhWNnRwcTJSY0NnWUFwOEl6TUkwS29Qbzc1eTZxMS9SekwKc2k0QlRnckpoMEp6TUE0RlpWWHQzaFFMZGhDaFRWck5OSm5hU3JjQ3FoKzRGRmJPb1FWNXhlTVVPcWthK3NpNgp3amVMKzJBOHRoZG5sWnVENHU5cEhRN2Y5M2MwQ2ZzM3BOYlhMQkRmS2dSaCt5L2tWaDhmdHQxQmFFOTd6RjBCCkV4WG9WclY5cHgxZzh3ZHJHbG5Xb1FLQmdDRVdRY1llMiswSVFUR2s3dEhLbU1FNmVUWUUrQVB4Qy9mcUFjTUIKNnFFV2dxVjB2S1pVbmcvQWlDUXJNWk5YM0RzKzRNeEI2em4rNHVmeVlRU3p5ZlhOTU1Mb3FQOTBzSVVXNXJCLwpGdlg1VTY0UjJuSUNuMmlHTGR6WjhyZlFzYTlWYzFMc2pXQlFQTnBZUEplVFZiQjVxZytGNjRmTXYyWEdpVVhkClZIZVBBb0dBSWJqRlpPM1hpNmxJaENHMDJYWXVrbjZVKzdvK2NKTXE5U1hDczh0MFZrQTllc1gwUkt2L1pidXQKMkhnL2FVMUdYd2IyT1EwdkhCZjJuZDBxYm9iUmdvS3ZTL3Nla0hDZXlFNXVZbk9YQitnMHFVRXMwa3Q2S0I0RAppRU1QQWxZaEgwcnZsRHBHT2ZqcGw4YlhpNXV2bjFyQUZXVGRQclNVcWdhbXA1MWF2OHM9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==',
12
+ }