@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.
- package/bin/index.js +7 -0
- package/discovery-router/Dockerfile +18 -0
- package/discovery-router/README.md +99 -0
- package/discovery-router/package.json +13 -0
- package/discovery-router/registry.example.json +5 -0
- package/discovery-router/server.js +272 -0
- package/lib/account.js +120 -0
- package/lib/auth-dev-keys.js +12 -0
- package/lib/bankid.js +130 -0
- package/lib/cli.js +27 -0
- package/lib/command/bankid.js +45 -0
- package/lib/command/customer.js +108 -0
- package/lib/command/db.js +156 -0
- package/lib/command/env.js +114 -0
- package/lib/command/logs.js +143 -0
- package/lib/command/measure.js +81 -0
- package/lib/command/service.js +166 -0
- package/lib/command/setup.js +92 -0
- package/lib/command/shred.js +60 -0
- package/lib/compose/README.md +98 -0
- package/lib/compose/generate.js +375 -0
- package/lib/compose/manifests.js +108 -0
- package/lib/db/roles.js +118 -0
- package/lib/discovery/client.js +40 -0
- package/lib/engine/GUIDE.md +176 -0
- package/lib/engine/PROCESS.md +571 -0
- package/lib/engine/dbbuild.js +325 -0
- package/lib/engine/gen-schema-map.js +479 -0
- package/lib/engine/purge.js +137 -0
- package/lib/engine/schema-map.json +11016 -0
- package/lib/engine/seed.js +1045 -0
- package/lib/obc.js +72 -0
- package/lib/registry.js +123 -0
- package/lib/runtime.js +101 -0
- package/lib/service-token.js +40 -0
- package/lib/shred/README.md +118 -0
- package/lib/shred/audit.js +128 -0
- package/lib/shred/faker.js +545 -0
- package/lib/shred/index.js +126 -0
- package/lib/shred/scripts/base-partner-emails.sql +9 -0
- package/lib/shred/scripts/dev-accounts.sql +195 -0
- package/lib/shred/scripts/emails.sql +48 -0
- package/lib/shred/scripts/institution-browser.sql +3 -0
- package/lib/shred/scripts/notification-targets.sql +5 -0
- package/lib/shred/scripts/partners.sql +2 -0
- package/lib/shred/scripts/passwords.sql +8 -0
- package/lib/shred/scripts/personal-numbers.sql +177 -0
- package/lib/shred/scripts/phone-numbers.sql +22 -0
- package/lib/shred/scripts/salary-spec-reports.sql +5 -0
- package/lib/shred/scripts/service-activity-tracker-data.sql +4 -0
- package/lib/shred/scripts/service-core-objects.sql +19 -0
- package/lib/shred/scripts/service-event-stream.sql +2 -0
- package/lib/shred/scripts/service-integrations.sql +4 -0
- package/lib/shred/scripts/template.sql +4 -0
- package/lib/shred/scripts/x-service-billing.sql +34 -0
- package/lib/shred/scripts/xxx-history-tables.sql +25 -0
- package/lib/stub.js +8 -0
- package/local-postgres/Dockerfile +11 -0
- package/package.json +46 -0
- package/readme.md +92 -0
package/bin/index.js
ADDED
|
@@ -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,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
|
+
}
|