@oimlsmart/platform-server 0.1.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/README.md +92 -0
- package/migrations/0001_init.sql +69 -0
- package/migrations/0002_identity.sql +24 -0
- package/migrations/0003_federation_peers.sql +21 -0
- package/migrations/0003_users_rbac.sql +8 -0
- package/migrations/0004_oidc_op.sql +61 -0
- package/migrations/0005_upstream_providers.sql +34 -0
- package/migrations/0006_op_accounts.sql +28 -0
- package/migrations/0007_org_join_requests.sql +26 -0
- package/migrations/0008_op_client_roles.sql +19 -0
- package/migrations/0009_account_console.sql +32 -0
- package/migrations/0009_sso_states.sql +13 -0
- package/migrations/0010_notify_events.sql +23 -0
- package/migrations/0011_op_launch.sql +18 -0
- package/migrations/0011_org_memberships.sql +62 -0
- package/migrations/0012_notify_subscriptions.sql +57 -0
- package/migrations/0012_strong_auth.sql +109 -0
- package/migrations/0013_org_registry.sql +53 -0
- package/migrations/0014_notify_inbox.sql +34 -0
- package/migrations/0015_certificate_holder_attribution.sql +63 -0
- package/migrations/0016_instrument_registrations.sql +78 -0
- package/package.json +52 -0
- package/src/client-info.ts +25 -0
- package/src/context.ts +31 -0
- package/src/github.ts +284 -0
- package/src/mailer.ts +309 -0
- package/src/oidc.ts +369 -0
- package/src/profile/node.ts +83 -0
- package/src/profile.ts +582 -0
- package/src/rbac/node.ts +42 -0
- package/src/rbac.ts +53 -0
- package/src/session.ts +45 -0
- package/src/store/d1.ts +2850 -0
- package/src/store/sqlite/entities.ts +71 -0
- package/src/store/sqlite/events.ts +82 -0
- package/src/store/sqlite/factors-store.ts +348 -0
- package/src/store/sqlite/notify.ts +247 -0
- package/src/store/sqlite/op-accounts-store.ts +470 -0
- package/src/store/sqlite/op-store.ts +280 -0
- package/src/store/sqlite/schema.sql +745 -0
- package/src/store/sqlite/store.ts +1390 -0
- package/src/store/sqlite/upstream-store.ts +148 -0
- package/src/store/sqlite.ts +1027 -0
- package/src/store.ts +1826 -0
- package/src/vocab/index.ts +12 -0
- package/src/vocab/permissions.ts +398 -0
- package/src/vocab/rbac.ts +281 -0
- package/src/vocab/roles.ts +162 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
-- Migration 0012 — the strong-authentication wave (TODO.identity-sso/02
|
|
2
|
+
-- passkeys + /03 the factor registry): passkeys (WebAuthn credentials),
|
|
3
|
+
-- TOTP authenticator apps, recovery codes, the one-time ceremony state
|
|
4
|
+
-- (WebAuthn challenges + the pending second-factor sign-in row), and the
|
|
5
|
+
-- authentication provenance (amr, RFC 8176) riding sessions → codes →
|
|
6
|
+
-- access tokens into the ID token.
|
|
7
|
+
-- schema.sql carries the same end state for fresh databases —
|
|
8
|
+
-- src/__tests__/d1-store.test.ts pins the UNION of every migration to
|
|
9
|
+
-- schema.sql's CREATE set.
|
|
10
|
+
|
|
11
|
+
-- The sign-in provenance (the session's amr list, a JSON array of RFC
|
|
12
|
+
-- 8176 values — 'pwd', 'otp', 'webauthn', 'hwk', plus the OP-private
|
|
13
|
+
-- 'recovery' for a recovery-code entry). NULL = no OP-side credential
|
|
14
|
+
-- event recorded (an upstream-provider sign-in).
|
|
15
|
+
ALTER TABLE sessions ADD COLUMN amr TEXT;
|
|
16
|
+
-- The consenting session's provenance, stamped on the one-time code so
|
|
17
|
+
-- the token endpoint emits the ID token's amr; carried onto the access
|
|
18
|
+
-- token so userinfo answers the same truth.
|
|
19
|
+
ALTER TABLE oidc_codes ADD COLUMN amr TEXT;
|
|
20
|
+
ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT;
|
|
21
|
+
|
|
22
|
+
-- The passkeys (wave 02). credential_id is the authenticator's own
|
|
23
|
+
-- (base64url); public_key is the COSE key bytes (base64url) as the
|
|
24
|
+
-- attestation carried them; sign_count is the authenticator's signature
|
|
25
|
+
-- counter (a REGRESSED count on assertion is the clone signal — the
|
|
26
|
+
-- advance is a guarded UPDATE, the regression refuses + audits);
|
|
27
|
+
-- aaguid + transports record what the browser declared (attestation is
|
|
28
|
+
-- 'none' at this assurance level — display hints, never proof).
|
|
29
|
+
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
|
30
|
+
credential_id TEXT PRIMARY KEY,
|
|
31
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
32
|
+
name TEXT NOT NULL,
|
|
33
|
+
public_key TEXT NOT NULL,
|
|
34
|
+
sign_count INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
aaguid TEXT,
|
|
36
|
+
transports TEXT,
|
|
37
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
38
|
+
last_used_at TEXT,
|
|
39
|
+
last_ip TEXT
|
|
40
|
+
);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials (user_id);
|
|
42
|
+
|
|
43
|
+
-- The TOTP authenticator apps (RFC 6238, 30 s step, 6 digits, HMAC-SHA-1).
|
|
44
|
+
-- verified_at NULL = the PENDING enrollment: it activates ONLY on the
|
|
45
|
+
-- first valid code, and the enrollment verify carries a hard throttle
|
|
46
|
+
-- (fail_count + last_failure_at — the six-digit window invites brute
|
|
47
|
+
-- force). The secret is base32 and never leaves the server after the
|
|
48
|
+
-- enrollment answer.
|
|
49
|
+
CREATE TABLE IF NOT EXISTS totp_secrets (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
52
|
+
name TEXT NOT NULL,
|
|
53
|
+
secret TEXT NOT NULL,
|
|
54
|
+
fail_count INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
last_failure_at TEXT,
|
|
56
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
57
|
+
verified_at TEXT,
|
|
58
|
+
last_used_at TEXT,
|
|
59
|
+
last_ip TEXT
|
|
60
|
+
);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_totp_secrets_user ON totp_secrets (user_id);
|
|
62
|
+
|
|
63
|
+
-- The recovery codes: generated at the first factor's enrollment, shown
|
|
64
|
+
-- once, stored HASHED (SHA-256 of the normalized code — 80 bits of
|
|
65
|
+
-- random per code, so an unsalted hash resists the offline attack), one
|
|
66
|
+
-- time each (consumed_at flips atomically). Regeneration REPLACES the
|
|
67
|
+
-- account's set (batch marks the generation; the old set is deleted with
|
|
68
|
+
-- the audit event).
|
|
69
|
+
CREATE TABLE IF NOT EXISTS recovery_codes (
|
|
70
|
+
id TEXT PRIMARY KEY,
|
|
71
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
72
|
+
batch TEXT NOT NULL,
|
|
73
|
+
code_hash TEXT NOT NULL,
|
|
74
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
75
|
+
consumed_at TEXT
|
|
76
|
+
);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user ON recovery_codes (user_id);
|
|
78
|
+
|
|
79
|
+
-- The one-time WebAuthn ceremony challenges (the database-is-the-proof
|
|
80
|
+
-- doctrine, the sso_states/enrollment_tokens pattern): short TTL,
|
|
81
|
+
-- consumed atomically. user_id binds the registration + the second-factor
|
|
82
|
+
-- assertion to the account; the PASSWORDLESS assertion's row carries NULL
|
|
83
|
+
-- (the asserted credential id resolves the account).
|
|
84
|
+
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
|
85
|
+
challenge TEXT PRIMARY KEY,
|
|
86
|
+
user_id TEXT REFERENCES users(id),
|
|
87
|
+
kind TEXT NOT NULL,
|
|
88
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
89
|
+
expires_at TEXT NOT NULL,
|
|
90
|
+
consumed_at TEXT
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user ON webauthn_challenges (user_id);
|
|
93
|
+
|
|
94
|
+
-- The pending second-factor sign-in: the password verified, the account
|
|
95
|
+
-- holds factors, the session waits on the factor. One-time (consumed at
|
|
96
|
+
-- completion), short TTL, and the per-account throttle rides the row:
|
|
97
|
+
-- fail_count + last_failure_at give the backoff ladder, and the cap
|
|
98
|
+
-- burns the attempt (audit + the account's lockout email).
|
|
99
|
+
CREATE TABLE IF NOT EXISTS mfa_pending (
|
|
100
|
+
token TEXT PRIMARY KEY,
|
|
101
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
102
|
+
amr TEXT NOT NULL,
|
|
103
|
+
fail_count INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
last_failure_at TEXT,
|
|
105
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
106
|
+
expires_at TEXT NOT NULL,
|
|
107
|
+
consumed_at TEXT
|
|
108
|
+
);
|
|
109
|
+
CREATE INDEX IF NOT EXISTS idx_mfa_pending_user ON mfa_pending (user_id);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- Migration 0013 — the organization registry (TODO.identity-features/05:
|
|
2
|
+
-- organizations as first-class citizens of the identity plane). One row
|
|
3
|
+
-- per organization: the stable SLUG id (for a participant org the OIML
|
|
4
|
+
-- code IS the id — the platform resolves the org claim against its own
|
|
5
|
+
-- participant registry directly, the mapping is identity, never a lookup
|
|
6
|
+
-- table), the display data, the OPTIONAL participant_ref annotation (the
|
|
7
|
+
-- link's documentation, never a key), and the lifecycle state.
|
|
8
|
+
--
|
|
9
|
+
-- Removal is DISABLE honestly (the route's act: the org's active
|
|
10
|
+
-- memberships disable, its members' per-org roles stop carrying; the
|
|
11
|
+
-- audit trail keeps the history); the erasure-adjacent hard delete
|
|
12
|
+
-- exists only for an org that never held a membership (the route
|
|
13
|
+
-- refuses it while a membership or a join request references the org).
|
|
14
|
+
--
|
|
15
|
+
-- The membership graph (org_memberships, migration 0011) references the
|
|
16
|
+
-- org by id WITHOUT a foreign key, deliberately: the scheme-side
|
|
17
|
+
-- participants register and this identity-side registry never merge
|
|
18
|
+
-- (the spec's §4), and a membership row's honesty (its lifecycle state)
|
|
19
|
+
-- never depends on a join.
|
|
20
|
+
--
|
|
21
|
+
-- No backfill, honestly: the production registry starts EMPTY and the
|
|
22
|
+
-- identity administrator adds the organizations deliberately (the wave's
|
|
23
|
+
-- whole point); the memberships reference org ids as plain strings, so
|
|
24
|
+
-- nothing breaks at the SQL level, and every read degrades to the raw
|
|
25
|
+
-- id until the admin curates the row. The dev/e2e posture seeds the
|
|
26
|
+
-- demonstration register's rows (server/seed-org-register.ts).
|
|
27
|
+
-- schema.sql carries the same end state for fresh databases —
|
|
28
|
+
-- src/__tests__/d1-store.test.ts pins the UNION of every migration to
|
|
29
|
+
-- schema.sql's CREATE set.
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS org_registry (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
name TEXT NOT NULL,
|
|
34
|
+
short_name TEXT,
|
|
35
|
+
-- The participant kind (the OIML-CS program's four); NULL = a
|
|
36
|
+
-- non-participant org (the estate operator's own org, a consumer).
|
|
37
|
+
kind TEXT,
|
|
38
|
+
country TEXT,
|
|
39
|
+
-- The contacts (a JSON array of { name, email }; a malformed entry is
|
|
40
|
+
-- skipped on read, never trusted).
|
|
41
|
+
contacts TEXT NOT NULL DEFAULT '[]',
|
|
42
|
+
-- The participant-link annotation (which participant record the org
|
|
43
|
+
-- mirrors); documentation only.
|
|
44
|
+
participant_ref TEXT,
|
|
45
|
+
state TEXT NOT NULL DEFAULT 'active',
|
|
46
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
47
|
+
created_by TEXT,
|
|
48
|
+
updated_at TEXT,
|
|
49
|
+
updated_by TEXT,
|
|
50
|
+
disabled_at TEXT,
|
|
51
|
+
disabled_by TEXT
|
|
52
|
+
);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- TODO.notify/03 — the inbox state (migration 0014 in the shared
|
|
2
|
+
-- numbering; 0013 is the org registry's, identity-features/05 merged
|
|
3
|
+
-- via #194 while this wave was in flight — the D1 migrations journal
|
|
4
|
+
-- keys on the FILE NAME, so the rename is the renumber). The
|
|
5
|
+
-- notification system's per-user per-event read markers
|
|
6
|
+
-- (TODO.notify/00's contract: "the inbox state
|
|
7
|
+
-- (D1): per-user per-event state (read / done / saved) written lazily at
|
|
8
|
+
-- read; the feed itself is COMPUTED at read"). One row per (user, event),
|
|
9
|
+
-- created at the first act on the row:
|
|
10
|
+
--
|
|
11
|
+
-- read_at the mark-read stamp (NULL = unread; the unread filter and
|
|
12
|
+
-- the unread count read it);
|
|
13
|
+
-- done_at the done stamp (NULL = in the inbox; done is the archive —
|
|
14
|
+
-- the row leaves the feed, the marker keeps the state honest).
|
|
15
|
+
--
|
|
16
|
+
-- 'saved' joins with wave 05 (the GitHub polish). The rows name the
|
|
17
|
+
-- user + the event WITHOUT foreign keys, deliberately: the demo reset
|
|
18
|
+
-- (and wave 04's retention sweep) deletes events while the user's
|
|
19
|
+
-- markers stay — the subscriptions store's own posture (the user's
|
|
20
|
+
-- state is their own, never the workflow's); a marker on a wiped event
|
|
21
|
+
-- simply never joins. The write path's guard is the integrity (a marker
|
|
22
|
+
-- lands only on an event that exists, is visible and is the caller's —
|
|
23
|
+
-- server/notify-inbox.ts's inboxEventVisibleTo), and the dev-reset's
|
|
24
|
+
-- OIDC-user wipe never blocks on a marker either. The d1-store suite's
|
|
25
|
+
-- drift tripwire pins this migration set's end state to schema.sql.
|
|
26
|
+
CREATE TABLE IF NOT EXISTS notify_inbox_state (
|
|
27
|
+
user_id TEXT NOT NULL,
|
|
28
|
+
event_id TEXT NOT NULL,
|
|
29
|
+
read_at TEXT,
|
|
30
|
+
done_at TEXT,
|
|
31
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
32
|
+
PRIMARY KEY (user_id, event_id)
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_notify_inbox_state_user ON notify_inbox_state (user_id);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- Migration 0015 — the register's holder-org attribution (TODO.register/02:
|
|
2
|
+
-- "my organization's certificates"). The certificate's holder becomes the
|
|
3
|
+
-- OP-minted ORG id end to end: the application binds the applicant account's
|
|
4
|
+
-- active org (applicant_org_id), the issued certificate carries it as the
|
|
5
|
+
-- holder-org descriptor (json_data.holder_org — additive entity content, no
|
|
6
|
+
-- schema change), the federation registration package transports it, and the
|
|
7
|
+
-- HUB stores the descriptor on the registered certificate as an ATTRIBUTION
|
|
8
|
+
-- ROW here — the hub's own record, never a write into the sender's imported
|
|
9
|
+
-- artifact.
|
|
10
|
+
--
|
|
11
|
+
-- THE NUMBERING: 0013_org_registry is merged; the notify-03 inbox wave holds
|
|
12
|
+
-- 0014; THIS wave (TODO.register/02) holds 0015; the serial-registration wave
|
|
13
|
+
-- (TODO.register/03) takes 0016. Expand-only: two new tables, no alterations.
|
|
14
|
+
-- The identity repo (oimlsmart/identity) mirrors this migration set
|
|
15
|
+
-- byte-identical (552's precedent) — the coordinator carries the mirror.
|
|
16
|
+
--
|
|
17
|
+
-- certificate_holder_orgs: ONE row per certificate (the primary key) — the
|
|
18
|
+
-- first attribution wins; the claim act's confirmation is the only other
|
|
19
|
+
-- writer (a claimed row is deliberate, never silently overwritten). The org
|
|
20
|
+
-- display name is DENORMALIZED at attribution time: the register reads
|
|
21
|
+
-- correctly even when the org later renames (the register's permanence rule).
|
|
22
|
+
--
|
|
23
|
+
-- certificate_holder_claims: the legacy-row CLAIM act's state machine. A
|
|
24
|
+
-- certificate registered before this program (or arriving via records mode /
|
|
25
|
+
-- CSV) carries a free-text holder and no descriptor; the manufacturer org's
|
|
26
|
+
-- administrator claims the row by holder-name match (the matched name is
|
|
27
|
+
-- snapshotted as the claim's evidence), an estate admin confirms or refuses
|
|
28
|
+
-- (atomic on 'pending'), and the audit chain (auditEvents) carries both acts.
|
|
29
|
+
-- A refused claim never blocks a fresh claim; a confirmed claim is terminal.
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
|
|
32
|
+
certificate_id TEXT PRIMARY KEY,
|
|
33
|
+
org_id TEXT NOT NULL,
|
|
34
|
+
-- The org's display name at attribution time (denormalized — the
|
|
35
|
+
-- register's permanence; never a join).
|
|
36
|
+
org_name TEXT NOT NULL,
|
|
37
|
+
-- 'registration' (the chain's carried descriptor, extracted by the
|
|
38
|
+
-- registrar's act) | 'claim' (the confirmed legacy-row claim).
|
|
39
|
+
source TEXT NOT NULL,
|
|
40
|
+
attributed_at TEXT NOT NULL,
|
|
41
|
+
attributed_by TEXT,
|
|
42
|
+
-- The confirming claim (source 'claim' only).
|
|
43
|
+
claim_id TEXT
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_certificate_holder_orgs_org ON certificate_holder_orgs (org_id);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS certificate_holder_claims (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
certificate_id TEXT NOT NULL,
|
|
50
|
+
claimant_org_id TEXT NOT NULL,
|
|
51
|
+
claimant_org_name TEXT NOT NULL,
|
|
52
|
+
-- The certificate's free-text holder name the claim matched (snapshot).
|
|
53
|
+
matched_holder_name TEXT NOT NULL,
|
|
54
|
+
claimed_by TEXT NOT NULL,
|
|
55
|
+
state TEXT NOT NULL DEFAULT 'pending',
|
|
56
|
+
decided_by TEXT,
|
|
57
|
+
decided_at TEXT,
|
|
58
|
+
refusal_reason TEXT,
|
|
59
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
60
|
+
);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_cert ON certificate_holder_claims (certificate_id);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_state ON certificate_holder_claims (state);
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_org ON certificate_holder_claims (claimant_org_id);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- Migration 0016 — the instrument register (TODO.register/03: the
|
|
2
|
+
-- serial-number registration interface). One row per REGISTERED
|
|
3
|
+
-- instrument: the serial number riding under a type certificate, the
|
|
4
|
+
-- holder organization (the manufacturer), the Recommendation the
|
|
5
|
+
-- certificate belongs to, the manufacture date, the per-serial
|
|
6
|
+
-- designations (the concrete scope parameters the scope check evaluated
|
|
7
|
+
-- — accuracy class, capacity, …), the scope verdict recorded AT
|
|
8
|
+
-- REGISTRATION (in_scope, or scope_unverified when the certificate's
|
|
9
|
+
-- scope block is not structured — the records-mode honest-degradation
|
|
10
|
+
-- posture), and the lifecycle (registered / out_of_service / withdrawn).
|
|
11
|
+
--
|
|
12
|
+
-- The scope REFUSAL never lands a row: an out-of-scope declaration is
|
|
13
|
+
-- refused with the reason by the route (the executing-scope doctrine at
|
|
14
|
+
-- the instrument level), so every row here is a registration that stood.
|
|
15
|
+
-- The batch import's per-row verdicts are answered to the caller, never
|
|
16
|
+
-- persisted; the committed rows are the valid ones (never a partial
|
|
17
|
+
-- SILENT import — the refusal report is the caller's record).
|
|
18
|
+
--
|
|
19
|
+
-- Uniqueness: one serial number per certificate (the register's whole
|
|
20
|
+
-- point — the same physical unit never registers twice under one type
|
|
21
|
+
-- certificate). The route turns the conflict into the honest 409.
|
|
22
|
+
--
|
|
23
|
+
-- NOT mirrored into the identity service's migration set: the byte-
|
|
24
|
+
-- identity rule covers the OP's tables, and the instrument register is
|
|
25
|
+
-- platform-side (the identity deploy never shares it — TODO.register/03
|
|
26
|
+
-- §The model). The holder_org_id references the identity plane's
|
|
27
|
+
-- organization by id WITHOUT a foreign key, deliberately — the same
|
|
28
|
+
-- posture org_memberships practices (migration 0011): the identity-side
|
|
29
|
+
-- registry and this platform-side register never merge, and a
|
|
30
|
+
-- registration row's honesty never depends on a join.
|
|
31
|
+
--
|
|
32
|
+
-- No backfill: the register starts empty and fills through the
|
|
33
|
+
-- registration acts (the UI, the CSV batch, the API). schema.sql
|
|
34
|
+
-- carries the same end state for fresh databases —
|
|
35
|
+
-- src/__tests__/d1-store.test.ts pins the UNION of every migration to
|
|
36
|
+
-- schema.sql's CREATE set.
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS instrument_registrations (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
-- The certificate the serial rides under (the entity store's
|
|
41
|
+
-- certificates row id — a platform reference, never a key into the
|
|
42
|
+
-- identity plane).
|
|
43
|
+
certificate_id TEXT NOT NULL,
|
|
44
|
+
-- The holder organization (the manufacturer org id; the identity
|
|
45
|
+
-- plane's org, referenced by id without a foreign key).
|
|
46
|
+
holder_org_id TEXT NOT NULL,
|
|
47
|
+
-- The Recommendation the certificate belongs to (the kind of
|
|
48
|
+
-- measuring instrument — certificates.standard_id).
|
|
49
|
+
standard_id TEXT NOT NULL,
|
|
50
|
+
serial_number TEXT NOT NULL,
|
|
51
|
+
-- The manufacture date (ISO date text; NULL when the declaration
|
|
52
|
+
-- did not carry it).
|
|
53
|
+
manufacture_date TEXT,
|
|
54
|
+
-- The per-serial designations the scope check evaluated (a JSON
|
|
55
|
+
-- object; e.g. { "accuracy_class": "C", "e_max": { "value": 2.2,
|
|
56
|
+
-- "unit": "t" } }).
|
|
57
|
+
designations TEXT NOT NULL DEFAULT '{}',
|
|
58
|
+
-- The scope verdict AT REGISTRATION: 'in_scope' (the structured
|
|
59
|
+
-- scope block covered the designations) or 'scope_unverified' (the
|
|
60
|
+
-- certificate carries no structured scope block — the records-mode
|
|
61
|
+
-- honest degradation; the IA's oversight surface sees exactly this
|
|
62
|
+
-- mark). The refused declaration never lands a row.
|
|
63
|
+
scope_status TEXT NOT NULL,
|
|
64
|
+
-- The verdict's record: the matched classification label (in_scope)
|
|
65
|
+
-- or the unverified note (scope_unverified).
|
|
66
|
+
scope_detail TEXT,
|
|
67
|
+
lifecycle TEXT NOT NULL DEFAULT 'registered',
|
|
68
|
+
-- The lifecycle act's provenance (the last act's actor + moment; the
|
|
69
|
+
-- registration itself stamps registered_at/by).
|
|
70
|
+
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
71
|
+
registered_by TEXT,
|
|
72
|
+
updated_at TEXT,
|
|
73
|
+
updated_by TEXT,
|
|
74
|
+
UNIQUE (certificate_id, serial_number)
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle);
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oimlsmart/platform-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/oimlsmart/platform-server.git"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
"./store": "./src/store.ts",
|
|
12
|
+
"./store/d1": "./src/store/d1.ts",
|
|
13
|
+
"./store/sqlite": "./src/store/sqlite.ts",
|
|
14
|
+
"./profile": "./src/profile.ts",
|
|
15
|
+
"./profile/node": "./src/profile/node.ts",
|
|
16
|
+
"./mailer": "./src/mailer.ts",
|
|
17
|
+
"./rbac": "./src/rbac.ts",
|
|
18
|
+
"./rbac/node": "./src/rbac/node.ts",
|
|
19
|
+
"./oidc": "./src/oidc.ts",
|
|
20
|
+
"./github": "./src/github.ts",
|
|
21
|
+
"./session": "./src/session.ts",
|
|
22
|
+
"./client-info": "./src/client-info.ts",
|
|
23
|
+
"./vocab": "./src/vocab/index.ts",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"migrations",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"typecheck": "tsc --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@cloudflare/workers-types": "^5.20260813.1",
|
|
37
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
38
|
+
"@types/js-yaml": "^4.0.9",
|
|
39
|
+
"@types/node": "^24.12.2",
|
|
40
|
+
"bcryptjs": "^3.0.3",
|
|
41
|
+
"better-sqlite3": "^12.10.0",
|
|
42
|
+
"hono": "^4.12.23",
|
|
43
|
+
"js-yaml": "^4.1.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"typescript": "~6.0.2",
|
|
47
|
+
"vitest": "^4.1.5"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// The request's client context (TODO.identity/06) — the user agent and
|
|
3
|
+
// the client IP, stamped on the session row at creation so the account
|
|
4
|
+
// console's sessions section can name every sign-in. The IP resolves
|
|
5
|
+
// from the platform's proxy headers (the Worker's cf-connecting-ip
|
|
6
|
+
// first, then the first x-forwarded-for hop, the rate-limit.ts rule);
|
|
7
|
+
// a request that carries neither records NULL, and the console says
|
|
8
|
+
// "not recorded" — never a guessed value.
|
|
9
|
+
//
|
|
10
|
+
// WORKER-SAFE: headers only, no node built-ins.
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
12
|
+
|
|
13
|
+
import type { KernelContext } from './context'
|
|
14
|
+
|
|
15
|
+
export interface ClientInfo {
|
|
16
|
+
userAgent: string | null
|
|
17
|
+
ip: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clientInfo(c: KernelContext): ClientInfo {
|
|
21
|
+
const ua = c.req.header('user-agent') ?? null
|
|
22
|
+
const forwarded = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
|
|
23
|
+
const ip = c.req.header('cf-connecting-ip') ?? (forwarded || null)
|
|
24
|
+
return { userAgent: ua || null, ip }
|
|
25
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// The kernel's request-context type (TODO.identity-extract/01).
|
|
3
|
+
//
|
|
4
|
+
// The kernel is consumed through a file: link in the monorepo and from
|
|
5
|
+
// npm by the identity service; in BOTH shapes its sources resolve hono
|
|
6
|
+
// from a DIFFERENT module instance than the consuming app's. hono's
|
|
7
|
+
// Context/HonoRequest carry a `unique symbol` brand (GET_MATCH_RESULT),
|
|
8
|
+
// so the two instances' Context types are nominally incompatible — a
|
|
9
|
+
// kernel helper typed on hono's Context would reject the app's contexts
|
|
10
|
+
// at the seam (and a tsconfig-paths type alias is not an answer: tsx
|
|
11
|
+
// applies tsconfig paths at RUNTIME and would resolve the value import
|
|
12
|
+
// to a .d.ts).
|
|
13
|
+
//
|
|
14
|
+
// So the kernel never names hono's Context in a public signature. It
|
|
15
|
+
// names THIS: the structural slice it actually touches (the request's
|
|
16
|
+
// header read + the env slot). hono's Context satisfies it structurally
|
|
17
|
+
// (HonoRequest.header has the `header(name: string)` overload); the
|
|
18
|
+
// hono boundary inside each helper holds one documented cast.
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
20
|
+
|
|
21
|
+
export interface KernelContext {
|
|
22
|
+
req: {
|
|
23
|
+
header(name: string): string | undefined
|
|
24
|
+
/** hono's HonoRequest.raw — the cookie helper reads the Cookie
|
|
25
|
+
* header off the raw request. */
|
|
26
|
+
raw: Request
|
|
27
|
+
}
|
|
28
|
+
/** hono's Context.env (process.env on node, the bindings on the
|
|
29
|
+
* Worker) — read through hono/adapter's env() at the seam. */
|
|
30
|
+
env?: unknown
|
|
31
|
+
}
|