@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.
Files changed (48) hide show
  1. package/README.md +92 -0
  2. package/migrations/0001_init.sql +69 -0
  3. package/migrations/0002_identity.sql +24 -0
  4. package/migrations/0003_federation_peers.sql +21 -0
  5. package/migrations/0003_users_rbac.sql +8 -0
  6. package/migrations/0004_oidc_op.sql +61 -0
  7. package/migrations/0005_upstream_providers.sql +34 -0
  8. package/migrations/0006_op_accounts.sql +28 -0
  9. package/migrations/0007_org_join_requests.sql +26 -0
  10. package/migrations/0008_op_client_roles.sql +19 -0
  11. package/migrations/0009_account_console.sql +32 -0
  12. package/migrations/0009_sso_states.sql +13 -0
  13. package/migrations/0010_notify_events.sql +23 -0
  14. package/migrations/0011_op_launch.sql +18 -0
  15. package/migrations/0011_org_memberships.sql +62 -0
  16. package/migrations/0012_notify_subscriptions.sql +57 -0
  17. package/migrations/0012_strong_auth.sql +109 -0
  18. package/migrations/0013_org_registry.sql +53 -0
  19. package/migrations/0014_notify_inbox.sql +34 -0
  20. package/migrations/0015_certificate_holder_attribution.sql +63 -0
  21. package/migrations/0016_instrument_registrations.sql +78 -0
  22. package/package.json +52 -0
  23. package/src/client-info.ts +25 -0
  24. package/src/context.ts +31 -0
  25. package/src/github.ts +284 -0
  26. package/src/mailer.ts +309 -0
  27. package/src/oidc.ts +369 -0
  28. package/src/profile/node.ts +83 -0
  29. package/src/profile.ts +582 -0
  30. package/src/rbac/node.ts +42 -0
  31. package/src/rbac.ts +53 -0
  32. package/src/session.ts +45 -0
  33. package/src/store/d1.ts +2850 -0
  34. package/src/store/sqlite/entities.ts +71 -0
  35. package/src/store/sqlite/events.ts +82 -0
  36. package/src/store/sqlite/factors-store.ts +348 -0
  37. package/src/store/sqlite/notify.ts +247 -0
  38. package/src/store/sqlite/op-accounts-store.ts +470 -0
  39. package/src/store/sqlite/op-store.ts +280 -0
  40. package/src/store/sqlite/schema.sql +745 -0
  41. package/src/store/sqlite/store.ts +1390 -0
  42. package/src/store/sqlite/upstream-store.ts +148 -0
  43. package/src/store/sqlite.ts +1027 -0
  44. package/src/store.ts +1826 -0
  45. package/src/vocab/index.ts +12 -0
  46. package/src/vocab/permissions.ts +398 -0
  47. package/src/vocab/rbac.ts +281 -0
  48. 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
+ }