@oimlsmart/platform-server 0.1.1 → 0.1.3
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/migrations/0018_notify_email.sql +51 -0
- package/package.json +1 -1
- package/src/store/d1.ts +153 -4
- package/src/store/sqlite/notify.ts +107 -0
- package/src/store/sqlite/schema.sql +27 -0
- package/src/store/sqlite/store.ts +29 -2
- package/src/store/sqlite.ts +47 -3
- package/src/store.ts +169 -9
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- TODO.notify/04 — the email channel's delivery store (migration 0018
|
|
2
|
+
-- in the shared numbering; 0017 is the cones wave's — identity-features
|
|
3
|
+
-- /09). One row per (event, recipient) — the fan-out's record of WHO
|
|
4
|
+
-- the event reached and on WHICH channel mark (TODO.notify/00: "the
|
|
5
|
+
-- delivery lands on the audit/notification store (a notification row
|
|
6
|
+
-- per recipient with the channel marks)"):
|
|
7
|
+
--
|
|
8
|
+
-- reason the resolution's STRONGEST reason (subscribed /
|
|
9
|
+
-- assigned / author / actor / role-default) — every
|
|
10
|
+
-- notification carries its why, the delivery row
|
|
11
|
+
-- included;
|
|
12
|
+
-- email the resolved email posture (immediate | digest | off)
|
|
13
|
+
-- — the channel split's outcome (the inbox is the
|
|
14
|
+
-- constant, never a column);
|
|
15
|
+
-- email_status NULL when email = 'off'; else the email leg's state:
|
|
16
|
+
-- sent | failed | rate_limited for the immediate leg,
|
|
17
|
+
-- digest_pending → digest_sent | digest_failed for the
|
|
18
|
+
-- daily rollup, digest_dropped when the event left the
|
|
19
|
+
-- store before the rollup, 'unavailable' when the
|
|
20
|
+
-- instance carries NO mailer (the honest degradation:
|
|
21
|
+
-- nothing queues — a console-posture instance marks the
|
|
22
|
+
-- row unavailable at event time and the digest never
|
|
23
|
+
-- accumulates);
|
|
24
|
+
-- email_at the terminal stamp (the send / the drop), NULL while
|
|
25
|
+
-- pending.
|
|
26
|
+
--
|
|
27
|
+
-- The rows name the user + the event WITHOUT foreign keys (the
|
|
28
|
+
-- subscriptions store's own posture, migration 0014's: the user's
|
|
29
|
+
-- delivery record is their own, never the workflow's) — the demo reset
|
|
30
|
+
-- and the retention sweep delete events while a delivery row simply
|
|
31
|
+
-- never joins. The UNIQUE (event_id, user_id) makes the fan-out
|
|
32
|
+
-- idempotent (a re-driven event updates, never duplicates). The
|
|
33
|
+
-- mailer's own audit (entity_type 'email') stands alongside — this
|
|
34
|
+
-- table is the PER-RECIPIENT notification record, the audit trail the
|
|
35
|
+
-- per-SEND one. The package's migrations test pins this set's end state
|
|
36
|
+
-- to schema.sql (src/store/sqlite) in lockstep.
|
|
37
|
+
CREATE TABLE IF NOT EXISTS notify_deliveries (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
event_id TEXT NOT NULL,
|
|
40
|
+
user_id TEXT NOT NULL,
|
|
41
|
+
reason TEXT NOT NULL,
|
|
42
|
+
email TEXT NOT NULL,
|
|
43
|
+
email_status TEXT,
|
|
44
|
+
email_at TEXT,
|
|
45
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
46
|
+
UNIQUE (event_id, user_id)
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id);
|
|
50
|
+
-- The digest rollup + the retry sweep read by status.
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/store/d1.ts
CHANGED
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
type IdentityLink,
|
|
36
36
|
type IdentityProvider,
|
|
37
37
|
type MfaPending,
|
|
38
|
+
type NotifyDelivery,
|
|
39
|
+
type NotifyDeliveryStatus,
|
|
38
40
|
type NotifyEntityMute,
|
|
39
41
|
type NotifyInboxState,
|
|
40
42
|
type NotifyPreferences,
|
|
@@ -62,6 +64,7 @@ import {
|
|
|
62
64
|
type InstrumentRegistrationScopeStatus,
|
|
63
65
|
type PlatformEvent,
|
|
64
66
|
resolveOrgContext,
|
|
67
|
+
parseOrgMemberCone,
|
|
65
68
|
type RecoveryCodeState,
|
|
66
69
|
type ServerStore,
|
|
67
70
|
type SessionView,
|
|
@@ -175,8 +178,9 @@ function toAdminRow(user: UserRecord & { last_login?: string | null; provider?:
|
|
|
175
178
|
* (none WITHOUT ROWID — schema.sql). The TODO.notify/01 event store
|
|
176
179
|
* wipes with them — its rows reference the workflow entities a reset
|
|
177
180
|
* removes (the feed's read-time visibility gate would drop the orphans
|
|
178
|
-
* anyway; wiping keeps the demo honest).
|
|
179
|
-
|
|
181
|
+
* anyway; wiping keeps the demo honest). TODO.notify/04: the delivery
|
|
182
|
+
* store wipes alongside (its rows reference the wiped events). */
|
|
183
|
+
const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations', 'notify_deliveries'] as const
|
|
180
184
|
|
|
181
185
|
export class D1ServerStore implements ServerStore {
|
|
182
186
|
constructor(private readonly db: D1Database) {}
|
|
@@ -258,6 +262,14 @@ export class D1ServerStore implements ServerStore {
|
|
|
258
262
|
).run()
|
|
259
263
|
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state)').run()
|
|
260
264
|
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state)').run()
|
|
265
|
+
// TODO.identity-features/09 (the org-member data cone): the cone
|
|
266
|
+
// column arrives with migration 0017 — a dev D1 predating it
|
|
267
|
+
// grows the column here. NULL = org-wide: existing memberships
|
|
268
|
+
// keep their posture silently.
|
|
269
|
+
const membershipCols = await this.db.prepare('PRAGMA table_info(org_memberships)').all<{ name: string }>()
|
|
270
|
+
if (!membershipCols.results.some(c => c.name === 'cone')) {
|
|
271
|
+
await this.db.prepare('ALTER TABLE org_memberships ADD COLUMN cone TEXT').run()
|
|
272
|
+
}
|
|
261
273
|
const sessionCols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
|
|
262
274
|
if (!sessionCols.results.some(c => c.name === 'active_org')) {
|
|
263
275
|
await this.db.prepare('ALTER TABLE sessions ADD COLUMN active_org TEXT').run()
|
|
@@ -569,7 +581,9 @@ export class D1ServerStore implements ServerStore {
|
|
|
569
581
|
// The stale stamp never lingers (the membership ended mid-session).
|
|
570
582
|
await this.stmt('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?', payload.id, activeOrg).run()
|
|
571
583
|
}
|
|
572
|
-
|
|
584
|
+
// TODO.identity-features/09: the context membership's cone rides the
|
|
585
|
+
// payload — the entity gates enforce it without a store round-trip.
|
|
586
|
+
return { ...payload, orgId: resolved.orgId, roles: resolved.roles, cone: resolved.cone }
|
|
573
587
|
}
|
|
574
588
|
|
|
575
589
|
async deleteSession(token: string): Promise<void> {
|
|
@@ -1986,6 +2000,9 @@ export class D1ServerStore implements ServerStore {
|
|
|
1986
2000
|
userId: row.user_id as string,
|
|
1987
2001
|
orgId: row.org_id as string,
|
|
1988
2002
|
roles,
|
|
2003
|
+
// The cone (TODO.identity-features/09): NULL parses to the
|
|
2004
|
+
// org-wide default — a pre-existing membership keeps its posture.
|
|
2005
|
+
cone: parseOrgMemberCone((row.cone as string | null) ?? null),
|
|
1989
2006
|
state: row.state as OrgMembershipState,
|
|
1990
2007
|
isPrimary: row.is_primary === 1,
|
|
1991
2008
|
invitedBy: (row.invited_by as string | null) ?? null,
|
|
@@ -2109,6 +2126,16 @@ export class D1ServerStore implements ServerStore {
|
|
|
2109
2126
|
return this.getOrgMembership(userId, orgId)
|
|
2110
2127
|
}
|
|
2111
2128
|
|
|
2129
|
+
/** Set the membership's data cone (TODO.identity-features/09): the
|
|
2130
|
+
* canonical spelling, or NULL for the org-wide default. */
|
|
2131
|
+
async setOrgMembershipCone(userId: string, orgId: string, cone: string | null): Promise<OrgMembership | null> {
|
|
2132
|
+
await this.ensureMembershipSupport()
|
|
2133
|
+
const existing = await this.getOrgMembership(userId, orgId)
|
|
2134
|
+
if (!existing) return null
|
|
2135
|
+
await this.stmt('UPDATE org_memberships SET cone = ? WHERE user_id = ? AND org_id = ?', cone, userId, orgId).run()
|
|
2136
|
+
return this.getOrgMembership(userId, orgId)
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2112
2139
|
/** Remove the row (the declined invitation; the erasure's cleanup). */
|
|
2113
2140
|
async deleteOrgMembership(userId: string, orgId: string): Promise<boolean> {
|
|
2114
2141
|
await this.ensureMembershipSupport()
|
|
@@ -2799,12 +2826,132 @@ export class D1ServerStore implements ServerStore {
|
|
|
2799
2826
|
return D1ServerStore.toNotifyInboxState(row!)
|
|
2800
2827
|
}
|
|
2801
2828
|
|
|
2829
|
+
// ── the email channel's delivery store (TODO.notify/04) ───────────
|
|
2830
|
+
// The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
|
|
2831
|
+
// The defensive ensure mirrors the instrument_registrations posture:
|
|
2832
|
+
// a dev D1 migrated from before migration 0018 lacks the table.
|
|
2833
|
+
|
|
2834
|
+
private notifyDeliverySupportEnsured: Promise<void> | null = null
|
|
2835
|
+
|
|
2836
|
+
private ensureNotifyDeliverySupport(): Promise<void> {
|
|
2837
|
+
if (!this.notifyDeliverySupportEnsured) {
|
|
2838
|
+
this.notifyDeliverySupportEnsured = (async () => {
|
|
2839
|
+
await this.db.prepare(
|
|
2840
|
+
`CREATE TABLE IF NOT EXISTS notify_deliveries (
|
|
2841
|
+
id TEXT PRIMARY KEY,
|
|
2842
|
+
event_id TEXT NOT NULL,
|
|
2843
|
+
user_id TEXT NOT NULL,
|
|
2844
|
+
reason TEXT NOT NULL,
|
|
2845
|
+
email TEXT NOT NULL,
|
|
2846
|
+
email_status TEXT,
|
|
2847
|
+
email_at TEXT,
|
|
2848
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2849
|
+
UNIQUE (event_id, user_id)
|
|
2850
|
+
)`,
|
|
2851
|
+
).run()
|
|
2852
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id)').run()
|
|
2853
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id)').run()
|
|
2854
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status)').run()
|
|
2855
|
+
})()
|
|
2856
|
+
}
|
|
2857
|
+
return this.notifyDeliverySupportEnsured
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
private static toNotifyDelivery(row: Record<string, unknown>): NotifyDelivery {
|
|
2861
|
+
return {
|
|
2862
|
+
id: row.id as string,
|
|
2863
|
+
eventId: row.event_id as string,
|
|
2864
|
+
userId: row.user_id as string,
|
|
2865
|
+
reason: row.reason as string,
|
|
2866
|
+
email: row.email as NotifyDelivery['email'],
|
|
2867
|
+
emailStatus: (row.email_status as NotifyDelivery['emailStatus']) ?? null,
|
|
2868
|
+
emailAt: (row.email_at as string | null) ?? null,
|
|
2869
|
+
createdAt: row.created_at as string,
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
async putNotifyDelivery(input: {
|
|
2874
|
+
id: string
|
|
2875
|
+
eventId: string
|
|
2876
|
+
userId: string
|
|
2877
|
+
reason: string
|
|
2878
|
+
email: NotifyDelivery['email']
|
|
2879
|
+
emailStatus: NotifyDelivery['emailStatus']
|
|
2880
|
+
}): Promise<NotifyDelivery> {
|
|
2881
|
+
await this.ensureNotifyDeliverySupport()
|
|
2882
|
+
await this.stmt(
|
|
2883
|
+
`INSERT INTO notify_deliveries (id, event_id, user_id, reason, email, email_status, email_at)
|
|
2884
|
+
VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL OR ? = 'digest_pending' THEN NULL ELSE datetime('now') END)
|
|
2885
|
+
ON CONFLICT (event_id, user_id) DO UPDATE SET
|
|
2886
|
+
reason = excluded.reason,
|
|
2887
|
+
email = excluded.email`,
|
|
2888
|
+
input.id, input.eventId, input.userId, input.reason, input.email, input.emailStatus, input.emailStatus, input.emailStatus,
|
|
2889
|
+
).run()
|
|
2890
|
+
const row = await this.stmt(
|
|
2891
|
+
'SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?', input.eventId, input.userId,
|
|
2892
|
+
).first<Record<string, unknown>>()
|
|
2893
|
+
return D1ServerStore.toNotifyDelivery(row!)
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
async getNotifyDelivery(eventId: string, userId: string): Promise<NotifyDelivery | null> {
|
|
2897
|
+
await this.ensureNotifyDeliverySupport()
|
|
2898
|
+
const row = await this.stmt(
|
|
2899
|
+
'SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?', eventId, userId,
|
|
2900
|
+
).first<Record<string, unknown>>()
|
|
2901
|
+
return row ? D1ServerStore.toNotifyDelivery(row) : null
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
async listNotifyDeliveriesForEvent(eventId: string): Promise<NotifyDelivery[]> {
|
|
2905
|
+
await this.ensureNotifyDeliverySupport()
|
|
2906
|
+
const res = await this.stmt(
|
|
2907
|
+
'SELECT * FROM notify_deliveries WHERE event_id = ? ORDER BY created_at, id', eventId,
|
|
2908
|
+
).all<Record<string, unknown>>()
|
|
2909
|
+
return res.results.map(D1ServerStore.toNotifyDelivery)
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
async notifyDigestPendingUsers(): Promise<string[]> {
|
|
2913
|
+
await this.ensureNotifyDeliverySupport()
|
|
2914
|
+
const res = await this.stmt(
|
|
2915
|
+
"SELECT DISTINCT user_id FROM notify_deliveries WHERE email_status = 'digest_pending' ORDER BY user_id",
|
|
2916
|
+
).all<{ user_id: string }>()
|
|
2917
|
+
return res.results.map(r => r.user_id)
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
async notifyDigestPendingForUser(userId: string): Promise<NotifyDelivery[]> {
|
|
2921
|
+
await this.ensureNotifyDeliverySupport()
|
|
2922
|
+
const res = await this.stmt(
|
|
2923
|
+
"SELECT * FROM notify_deliveries WHERE user_id = ? AND email_status = 'digest_pending' ORDER BY created_at, id", userId,
|
|
2924
|
+
).all<Record<string, unknown>>()
|
|
2925
|
+
return res.results.map(D1ServerStore.toNotifyDelivery)
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
async notifyFailedDeliveries(limit = 100): Promise<NotifyDelivery[]> {
|
|
2929
|
+
await this.ensureNotifyDeliverySupport()
|
|
2930
|
+
const res = await this.stmt(
|
|
2931
|
+
"SELECT * FROM notify_deliveries WHERE email_status = 'failed' ORDER BY created_at, id LIMIT ?", limit,
|
|
2932
|
+
).all<Record<string, unknown>>()
|
|
2933
|
+
return res.results.map(D1ServerStore.toNotifyDelivery)
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
/** The status mark: the terminal marks stamp email_at; a re-queue to
|
|
2937
|
+
* 'digest_pending' CLEARS it (a pending row carries no stamp). */
|
|
2938
|
+
async markNotifyDelivery(id: string, status: NotifyDeliveryStatus): Promise<void> {
|
|
2939
|
+
await this.ensureNotifyDeliverySupport()
|
|
2940
|
+
if (status === 'digest_pending') {
|
|
2941
|
+
await this.stmt('UPDATE notify_deliveries SET email_status = ?, email_at = NULL WHERE id = ?', status, id).run()
|
|
2942
|
+
} else {
|
|
2943
|
+
await this.stmt("UPDATE notify_deliveries SET email_status = ?, email_at = datetime('now') WHERE id = ?", status, id).run()
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2802
2947
|
// ── provisioning / dev support ───────────────────────────────────
|
|
2803
2948
|
|
|
2804
2949
|
async wipeWorkflowStores(range?: { after: number; through: number }): Promise<number> {
|
|
2805
2950
|
// The register table joins the wipe defensively (a dev D1 migrated
|
|
2806
|
-
// from before migration 0016 lacks it — the ensure posture).
|
|
2951
|
+
// from before migration 0016 lacks it — the ensure posture). The
|
|
2952
|
+
// delivery store (0018) the same.
|
|
2807
2953
|
await this.ensureInstrumentRegistrationSupport()
|
|
2954
|
+
await this.ensureNotifyDeliverySupport()
|
|
2808
2955
|
// The wipe's tables in one batch (all-or-nothing, the putEntity
|
|
2809
2956
|
// pattern). A ranged round charges one bounded statement per table;
|
|
2810
2957
|
// the range-less form is the direct-call default (small stores,
|
|
@@ -2818,12 +2965,14 @@ export class D1ServerStore implements ServerStore {
|
|
|
2818
2965
|
|
|
2819
2966
|
async workflowStoreRowCeiling(): Promise<number> {
|
|
2820
2967
|
await this.ensureInstrumentRegistrationSupport()
|
|
2968
|
+
await this.ensureNotifyDeliverySupport()
|
|
2821
2969
|
const row = await this.stmt(
|
|
2822
2970
|
`SELECT MAX(ceiling) AS ceiling FROM (
|
|
2823
2971
|
SELECT MAX(rowid) AS ceiling FROM entity_changes
|
|
2824
2972
|
UNION ALL SELECT MAX(rowid) FROM evidence_records
|
|
2825
2973
|
UNION ALL SELECT MAX(rowid) FROM entities
|
|
2826
2974
|
UNION ALL SELECT MAX(rowid) FROM events
|
|
2975
|
+
UNION ALL SELECT MAX(rowid) FROM notify_deliveries
|
|
2827
2976
|
UNION ALL SELECT MAX(rowid) FROM instrument_registrations)`,
|
|
2828
2977
|
).first<{ ceiling: number | null }>()
|
|
2829
2978
|
return row?.ceiling ?? 0
|
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
// per-event read/done markers the computed feed joins (one row per
|
|
17
17
|
// (user, event), written lazily at the first act on the row).
|
|
18
18
|
//
|
|
19
|
+
// Plus TODO.notify/04's delivery store: notify_deliveries, one row per
|
|
20
|
+
// (event, recipient) — the email channel's per-recipient record (the
|
|
21
|
+
// reason, the resolved posture, the email leg's state; the digest
|
|
22
|
+
// sweep's queue and the retry sweep's read).
|
|
23
|
+
//
|
|
19
24
|
// The D1 store (../d1.ts) runs the SAME statements against the binding;
|
|
20
25
|
// the d1-store suite's tripwire pins the two schemas in lockstep.
|
|
21
26
|
// ═══════════════════════════════════════════════════════════════════
|
|
22
27
|
|
|
23
28
|
import { getDb } from './store'
|
|
24
29
|
import type {
|
|
30
|
+
NotifyDelivery,
|
|
25
31
|
NotifyEntityMute,
|
|
26
32
|
NotifyInboxState,
|
|
27
33
|
NotifyPreferences,
|
|
@@ -245,3 +251,104 @@ export function putNotifyInboxState(input: {
|
|
|
245
251
|
.get(input.userId, input.eventId) as InboxStateRow,
|
|
246
252
|
)
|
|
247
253
|
}
|
|
254
|
+
|
|
255
|
+
// ── the email channel's delivery store (TODO.notify/04) ──────────────
|
|
256
|
+
|
|
257
|
+
interface DeliveryRow {
|
|
258
|
+
id: string
|
|
259
|
+
event_id: string
|
|
260
|
+
user_id: string
|
|
261
|
+
reason: string
|
|
262
|
+
email: string
|
|
263
|
+
email_status: string | null
|
|
264
|
+
email_at: string | null
|
|
265
|
+
created_at: string
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function toNotifyDelivery(row: DeliveryRow): NotifyDelivery {
|
|
269
|
+
return {
|
|
270
|
+
id: row.id,
|
|
271
|
+
eventId: row.event_id,
|
|
272
|
+
userId: row.user_id,
|
|
273
|
+
reason: row.reason,
|
|
274
|
+
email: row.email as NotifyDelivery['email'],
|
|
275
|
+
emailStatus: row.email_status as NotifyDelivery['emailStatus'],
|
|
276
|
+
emailAt: row.email_at,
|
|
277
|
+
createdAt: row.created_at,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The fan-out's write: the upsert on UNIQUE (event_id, user_id). The
|
|
282
|
+
* status only ever moves FORWARD through the caller's marks — the
|
|
283
|
+
* upsert itself never overwrites a terminal stamp (a re-driven event
|
|
284
|
+
* refreshes reason + posture, keeps the email leg's state). An INITIAL
|
|
285
|
+
* write carrying a terminal status stamps email_at (pending states —
|
|
286
|
+
* NULL and 'digest_pending' — never carry a stamp). */
|
|
287
|
+
export function putNotifyDelivery(input: {
|
|
288
|
+
id: string
|
|
289
|
+
eventId: string
|
|
290
|
+
userId: string
|
|
291
|
+
reason: string
|
|
292
|
+
email: NotifyDelivery['email']
|
|
293
|
+
emailStatus: NotifyDelivery['emailStatus']
|
|
294
|
+
}): NotifyDelivery {
|
|
295
|
+
const db = getDb()
|
|
296
|
+
db.prepare(
|
|
297
|
+
`INSERT INTO notify_deliveries (id, event_id, user_id, reason, email, email_status, email_at)
|
|
298
|
+
VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL OR ? = 'digest_pending' THEN NULL ELSE datetime('now') END)
|
|
299
|
+
ON CONFLICT (event_id, user_id) DO UPDATE SET
|
|
300
|
+
reason = excluded.reason,
|
|
301
|
+
email = excluded.email`,
|
|
302
|
+
).run(input.id, input.eventId, input.userId, input.reason, input.email, input.emailStatus, input.emailStatus, input.emailStatus)
|
|
303
|
+
return toNotifyDelivery(
|
|
304
|
+
db.prepare('SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?')
|
|
305
|
+
.get(input.eventId, input.userId) as DeliveryRow,
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function getNotifyDelivery(eventId: string, userId: string): NotifyDelivery | null {
|
|
310
|
+
const row = getDb()
|
|
311
|
+
.prepare('SELECT * FROM notify_deliveries WHERE event_id = ? AND user_id = ?')
|
|
312
|
+
.get(eventId, userId) as DeliveryRow | undefined
|
|
313
|
+
return row ? toNotifyDelivery(row) : null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function listNotifyDeliveriesForEvent(eventId: string): NotifyDelivery[] {
|
|
317
|
+
const rows = getDb()
|
|
318
|
+
.prepare('SELECT * FROM notify_deliveries WHERE event_id = ? ORDER BY created_at, id')
|
|
319
|
+
.all(eventId) as DeliveryRow[]
|
|
320
|
+
return rows.map(toNotifyDelivery)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The digest sweep's first read: the DISTINCT users holding pending
|
|
324
|
+
* rows (one digest message per user per run). */
|
|
325
|
+
export function notifyDigestPendingUsers(): string[] {
|
|
326
|
+
const rows = getDb()
|
|
327
|
+
.prepare("SELECT DISTINCT user_id FROM notify_deliveries WHERE email_status = 'digest_pending' ORDER BY user_id")
|
|
328
|
+
.all() as Array<{ user_id: string }>
|
|
329
|
+
return rows.map(r => r.user_id)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function notifyDigestPendingForUser(userId: string): NotifyDelivery[] {
|
|
333
|
+
const rows = getDb()
|
|
334
|
+
.prepare("SELECT * FROM notify_deliveries WHERE user_id = ? AND email_status = 'digest_pending' ORDER BY created_at, id")
|
|
335
|
+
.all(userId) as DeliveryRow[]
|
|
336
|
+
return rows.map(toNotifyDelivery)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The retry sweep's read: the failed immediates, oldest first. */
|
|
340
|
+
export function notifyFailedDeliveries(limit = 100): NotifyDelivery[] {
|
|
341
|
+
const rows = getDb()
|
|
342
|
+
.prepare("SELECT * FROM notify_deliveries WHERE email_status = 'failed' ORDER BY created_at, id LIMIT ?")
|
|
343
|
+
.all(limit) as DeliveryRow[]
|
|
344
|
+
return rows.map(toNotifyDelivery)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The status mark. The terminal marks stamp email_at; a re-queue to
|
|
348
|
+
* 'digest_pending' CLEARS it (a pending row carries no stamp). */
|
|
349
|
+
export function markNotifyDelivery(id: string, status: NonNullable<NotifyDelivery['emailStatus']>): void {
|
|
350
|
+
const terminal = status === 'digest_pending' ? null : "datetime('now')"
|
|
351
|
+
getDb()
|
|
352
|
+
.prepare(`UPDATE notify_deliveries SET email_status = ?, email_at = ${terminal ?? 'NULL'} WHERE id = ?`)
|
|
353
|
+
.run(status, id)
|
|
354
|
+
}
|
|
@@ -220,6 +220,33 @@ CREATE TABLE IF NOT EXISTS notify_inbox_state (
|
|
|
220
220
|
);
|
|
221
221
|
CREATE INDEX IF NOT EXISTS idx_notify_inbox_state_user ON notify_inbox_state (user_id);
|
|
222
222
|
|
|
223
|
+
-- TODO.notify/04 — the email channel's delivery store (migration 0018):
|
|
224
|
+
-- one row per (event, recipient), the fan-out's record of who the event
|
|
225
|
+
-- reached and on which channel mark. reason is the resolution's
|
|
226
|
+
-- strongest reason; email the resolved posture (immediate | digest |
|
|
227
|
+
-- off — the inbox is the constant, never a column); email_status the
|
|
228
|
+
-- email leg's state (NULL when off; sent | failed | rate_limited;
|
|
229
|
+
-- digest_pending → digest_sent | digest_failed | digest_dropped;
|
|
230
|
+
-- 'unavailable' on a mailer-less instance — nothing queues there).
|
|
231
|
+
-- NO foreign keys (the subscriptions store's posture): a wiped event
|
|
232
|
+
-- simply never joins. UNIQUE (event_id, user_id) keeps the fan-out
|
|
233
|
+
-- idempotent. The mailer's per-SEND audit (entity_type 'email') stands
|
|
234
|
+
-- alongside — this table is the PER-RECIPIENT record.
|
|
235
|
+
CREATE TABLE IF NOT EXISTS notify_deliveries (
|
|
236
|
+
id TEXT PRIMARY KEY,
|
|
237
|
+
event_id TEXT NOT NULL,
|
|
238
|
+
user_id TEXT NOT NULL,
|
|
239
|
+
reason TEXT NOT NULL,
|
|
240
|
+
email TEXT NOT NULL,
|
|
241
|
+
email_status TEXT,
|
|
242
|
+
email_at TEXT,
|
|
243
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
244
|
+
UNIQUE (event_id, user_id)
|
|
245
|
+
);
|
|
246
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id);
|
|
247
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id);
|
|
248
|
+
CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status);
|
|
249
|
+
|
|
223
250
|
-- The evidence store (TODO.ops/09 — the monitor daemon's durable
|
|
224
251
|
-- streams): append-only records across restarts. The adapter contract
|
|
225
252
|
-- (src/evidence-store/adapter.ts) is tiny: append, query, getByIds,
|
|
@@ -107,6 +107,14 @@ function migrateAuthTables(db: Database.Database): void {
|
|
|
107
107
|
'active', 1, COALESCE(last_login, created_at)
|
|
108
108
|
FROM users WHERE org_id IS NOT NULL
|
|
109
109
|
`)
|
|
110
|
+
// TODO.identity-features/09 (the org-member data cone): the
|
|
111
|
+
// membership's cone column arrives with migration 0017 — a dev file
|
|
112
|
+
// predating it grows the column here. NULL = org-wide: existing
|
|
113
|
+
// memberships keep their posture silently.
|
|
114
|
+
const membershipCols = db.prepare('PRAGMA table_info(org_memberships)').all() as Array<{ name: string }>
|
|
115
|
+
if (membershipCols.length && !membershipCols.some(c => c.name === 'cone')) {
|
|
116
|
+
db.exec('ALTER TABLE org_memberships ADD COLUMN cone TEXT')
|
|
117
|
+
}
|
|
110
118
|
// TODO.identity-sso/02+03 (the strong-authentication wave): the amr
|
|
111
119
|
// provenance columns on sessions → codes → access tokens.
|
|
112
120
|
if (!sessionCols.some(c => c.name === 'amr')) {
|
|
@@ -781,13 +789,16 @@ export function findPendingOrgJoinRequestByEmail(email: string): OrgJoinRequest
|
|
|
781
789
|
// AS; the columns' last writer never resurrects a disabled membership.
|
|
782
790
|
|
|
783
791
|
import type { OrgMembership, OrgMembershipState } from '../../store'
|
|
784
|
-
import { resolveOrgContext } from '../../store'
|
|
792
|
+
import { resolveOrgContext, parseOrgMemberCone } from '../../store'
|
|
785
793
|
|
|
786
794
|
interface OrgMembershipRow {
|
|
787
795
|
id: string
|
|
788
796
|
user_id: string
|
|
789
797
|
org_id: string
|
|
790
798
|
roles: string
|
|
799
|
+
/** TODO.identity-features/09 — the membership's data cone (the nullable
|
|
800
|
+
* column; absent on a pre-migration row read). */
|
|
801
|
+
cone?: string | null
|
|
791
802
|
state: OrgMembershipState
|
|
792
803
|
is_primary: number
|
|
793
804
|
invited_by: string | null
|
|
@@ -803,6 +814,9 @@ function membershipPayload(row: OrgMembershipRow): OrgMembership {
|
|
|
803
814
|
userId: row.user_id,
|
|
804
815
|
orgId: row.org_id,
|
|
805
816
|
roles: parseRoles(row.roles) ?? [],
|
|
817
|
+
// The cone (TODO.identity-features/09): NULL parses to the org-wide
|
|
818
|
+
// default — a pre-existing membership keeps its posture silently.
|
|
819
|
+
cone: parseOrgMemberCone(row.cone),
|
|
806
820
|
state: row.state,
|
|
807
821
|
isPrimary: row.is_primary === 1,
|
|
808
822
|
invitedBy: row.invited_by,
|
|
@@ -844,7 +858,9 @@ function applySessionOrgContext(activeOrg: string | null, payload: AuthUserPaylo
|
|
|
844
858
|
if (activeOrg && !(active && active.state === 'active')) {
|
|
845
859
|
getDb().prepare('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?').run(payload.id, activeOrg)
|
|
846
860
|
}
|
|
847
|
-
|
|
861
|
+
// TODO.identity-features/09: the context membership's cone rides the
|
|
862
|
+
// payload — the entity gates enforce it without a store round-trip.
|
|
863
|
+
return { ...payload, orgId: resolved.orgId, roles: resolved.roles, cone: resolved.cone }
|
|
848
864
|
}
|
|
849
865
|
|
|
850
866
|
export function listOrgMemberships(userId: string): OrgMembership[] {
|
|
@@ -938,6 +954,17 @@ export function setOrgMembershipState(
|
|
|
938
954
|
return getOrgMembership(userId, orgId)
|
|
939
955
|
}
|
|
940
956
|
|
|
957
|
+
/** Set the membership's data cone (TODO.identity-features/09): the
|
|
958
|
+
* canonical column spelling, or NULL for the org-wide default. Answers
|
|
959
|
+
* null when no membership exists. */
|
|
960
|
+
export function setOrgMembershipCone(userId: string, orgId: string, cone: string | null): OrgMembership | null {
|
|
961
|
+
const db = getDb()
|
|
962
|
+
const existing = getOrgMembership(userId, orgId)
|
|
963
|
+
if (!existing) return null
|
|
964
|
+
db.prepare('UPDATE org_memberships SET cone = ? WHERE user_id = ? AND org_id = ?').run(cone, userId, orgId)
|
|
965
|
+
return getOrgMembership(userId, orgId)
|
|
966
|
+
}
|
|
967
|
+
|
|
941
968
|
/** Remove the row (the holder declining an invitation; the erasure's
|
|
942
969
|
* cleanup). The route refuses the PRIMARY membership. */
|
|
943
970
|
export function deleteOrgMembership(userId: string, orgId: string): boolean {
|
package/src/store/sqlite.ts
CHANGED
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
setInstrumentRegistrationLifecycle,
|
|
68
68
|
setOrgMembershipRoles,
|
|
69
69
|
setOrgMembershipState,
|
|
70
|
+
setOrgMembershipCone,
|
|
70
71
|
setOrgRegistryOrgState,
|
|
71
72
|
setSessionActiveOrg,
|
|
72
73
|
setUserActive,
|
|
@@ -95,12 +96,19 @@ import {
|
|
|
95
96
|
import {
|
|
96
97
|
deleteNotifyEntityMute,
|
|
97
98
|
deleteNotifyRule,
|
|
99
|
+
getNotifyDelivery,
|
|
98
100
|
getNotifyPreferences,
|
|
101
|
+
listNotifyDeliveriesForEvent,
|
|
99
102
|
listNotifyEntityMutes,
|
|
100
103
|
listNotifyInboxStates,
|
|
101
104
|
listNotifyRules,
|
|
105
|
+
markNotifyDelivery,
|
|
106
|
+
notifyDigestPendingForUser,
|
|
107
|
+
notifyDigestPendingUsers,
|
|
102
108
|
notifyEntityMutesForEvent,
|
|
109
|
+
notifyFailedDeliveries,
|
|
103
110
|
notifyRulesForEvent,
|
|
111
|
+
putNotifyDelivery,
|
|
104
112
|
putNotifyEntityMute,
|
|
105
113
|
putNotifyInboxState,
|
|
106
114
|
putNotifyPreferences,
|
|
@@ -164,7 +172,7 @@ import {
|
|
|
164
172
|
updateOpAccount,
|
|
165
173
|
updateUserName,
|
|
166
174
|
} from './sqlite/op-accounts-store'
|
|
167
|
-
import { installStore, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
|
|
175
|
+
import { installStore, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
|
|
168
176
|
import {
|
|
169
177
|
advanceWebauthnCounter,
|
|
170
178
|
consumeMfaPending,
|
|
@@ -198,8 +206,10 @@ import {
|
|
|
198
206
|
* gate would drop the orphans anyway; wiping keeps the demo honest).
|
|
199
207
|
* TODO.register/03: the instrument register wipes too — the e2e
|
|
200
208
|
* isolation contract resets it with the rest of the mutable workflow
|
|
201
|
-
* state.
|
|
202
|
-
|
|
209
|
+
* state. TODO.notify/04: the delivery store wipes alongside — its rows
|
|
210
|
+
* reference the wiped events (the per-recipient record is the
|
|
211
|
+
* workflow's, never the user's own state). */
|
|
212
|
+
const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations', 'notify_deliveries'] as const
|
|
203
213
|
|
|
204
214
|
export function createSqliteServerStore(): ServerStore {
|
|
205
215
|
return {
|
|
@@ -412,6 +422,9 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
412
422
|
): Promise<OrgMembership | null> {
|
|
413
423
|
return setOrgMembershipState(userId, orgId, state, actor)
|
|
414
424
|
},
|
|
425
|
+
async setOrgMembershipCone(userId: string, orgId: string, cone: string | null): Promise<OrgMembership | null> {
|
|
426
|
+
return setOrgMembershipCone(userId, orgId, cone)
|
|
427
|
+
},
|
|
415
428
|
async deleteOrgMembership(userId: string, orgId: string): Promise<boolean> {
|
|
416
429
|
return deleteOrgMembership(userId, orgId)
|
|
417
430
|
},
|
|
@@ -958,6 +971,36 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
958
971
|
return putNotifyInboxState(input)
|
|
959
972
|
},
|
|
960
973
|
|
|
974
|
+
// ── the email channel's delivery store (TODO.notify/04) ──
|
|
975
|
+
async putNotifyDelivery(input: {
|
|
976
|
+
id: string
|
|
977
|
+
eventId: string
|
|
978
|
+
userId: string
|
|
979
|
+
reason: string
|
|
980
|
+
email: NotifyDelivery['email']
|
|
981
|
+
emailStatus: NotifyDeliveryStatus | null
|
|
982
|
+
}): Promise<NotifyDelivery> {
|
|
983
|
+
return putNotifyDelivery(input)
|
|
984
|
+
},
|
|
985
|
+
async getNotifyDelivery(eventId: string, userId: string): Promise<NotifyDelivery | null> {
|
|
986
|
+
return getNotifyDelivery(eventId, userId)
|
|
987
|
+
},
|
|
988
|
+
async listNotifyDeliveriesForEvent(eventId: string): Promise<NotifyDelivery[]> {
|
|
989
|
+
return listNotifyDeliveriesForEvent(eventId)
|
|
990
|
+
},
|
|
991
|
+
async notifyDigestPendingUsers(): Promise<string[]> {
|
|
992
|
+
return notifyDigestPendingUsers()
|
|
993
|
+
},
|
|
994
|
+
async notifyDigestPendingForUser(userId: string): Promise<NotifyDelivery[]> {
|
|
995
|
+
return notifyDigestPendingForUser(userId)
|
|
996
|
+
},
|
|
997
|
+
async notifyFailedDeliveries(limit?: number): Promise<NotifyDelivery[]> {
|
|
998
|
+
return notifyFailedDeliveries(limit)
|
|
999
|
+
},
|
|
1000
|
+
async markNotifyDelivery(id: string, status: NotifyDeliveryStatus): Promise<void> {
|
|
1001
|
+
markNotifyDelivery(id, status)
|
|
1002
|
+
},
|
|
1003
|
+
|
|
961
1004
|
// ── provisioning / dev support ──
|
|
962
1005
|
async wipeWorkflowStores(range?: { after: number; through: number }): Promise<number> {
|
|
963
1006
|
const db = getDb()
|
|
@@ -979,6 +1022,7 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
979
1022
|
UNION ALL SELECT MAX(rowid) FROM evidence_records
|
|
980
1023
|
UNION ALL SELECT MAX(rowid) FROM entities
|
|
981
1024
|
UNION ALL SELECT MAX(rowid) FROM events
|
|
1025
|
+
UNION ALL SELECT MAX(rowid) FROM notify_deliveries
|
|
982
1026
|
UNION ALL SELECT MAX(rowid) FROM instrument_registrations)`,
|
|
983
1027
|
).get() as { ceiling: number | null }
|
|
984
1028
|
return row.ceiling ?? 0
|
package/src/store.ts
CHANGED
|
@@ -40,6 +40,15 @@ export interface AuthUserPayload {
|
|
|
40
40
|
* (absent = the primary role only). */
|
|
41
41
|
roles?: string[]
|
|
42
42
|
orgId: string | null
|
|
43
|
+
/** TODO.identity-features/09 — the ACTIVE org context's data cone (the
|
|
44
|
+
* membership's posture, resolved by the session-backed read; the OP's
|
|
45
|
+
* `cone` claim carries the same value). ABSENT on constructors that
|
|
46
|
+
* never resolve a membership (the row-backed reads); NULL = the
|
|
47
|
+
* context resolved no membership (an org-free account — the cone
|
|
48
|
+
* never applies to it). The cone only ever NARROWS: the enforcement
|
|
49
|
+
* reads it at the two choke points (the entity API's read/write
|
|
50
|
+
* gates), never as a grant. */
|
|
51
|
+
cone?: OrgMemberCone | null
|
|
43
52
|
avatarUrl?: string
|
|
44
53
|
/** TODO.identity/04: the account's sign-in provider family ('demo',
|
|
45
54
|
* 'github', 'oidc', the OP's account provider) — projected from the
|
|
@@ -269,6 +278,44 @@ export interface NotifyInboxState {
|
|
|
269
278
|
createdAt: string
|
|
270
279
|
}
|
|
271
280
|
|
|
281
|
+
// ── the email channel's delivery store (TODO.notify/04) ──────────────
|
|
282
|
+
|
|
283
|
+
/** The email leg's state on a delivery row: NULL when the resolved
|
|
284
|
+
* posture is 'off' (the inbox carries the event, the mailbox never
|
|
285
|
+
* does); 'sent' / 'failed' / 'rate_limited' for the immediate leg (the
|
|
286
|
+
* retry sweep re-attempts 'failed'); 'digest_pending' → 'digest_sent' /
|
|
287
|
+
* 'digest_failed' for the daily rollup ('digest_dropped' when the event
|
|
288
|
+
* left the store before the rollup ran); 'unavailable' when the
|
|
289
|
+
* instance carries NO mailer (the honest degradation: nothing queues —
|
|
290
|
+
* a console-posture instance marks the row at event time and the digest
|
|
291
|
+
* never accumulates). */
|
|
292
|
+
export type NotifyDeliveryStatus =
|
|
293
|
+
| 'sent' | 'failed' | 'rate_limited' | 'unavailable'
|
|
294
|
+
| 'digest_pending' | 'digest_sent' | 'digest_failed' | 'digest_dropped'
|
|
295
|
+
|
|
296
|
+
/** One delivery row (TODO.notify/00: "a notification row per recipient
|
|
297
|
+
* with the channel marks"): the fan-out's record that the event reached
|
|
298
|
+
* this user — the resolution's STRONGEST reason, the resolved email
|
|
299
|
+
* posture, and the email leg's state. The inbox is the constant, never
|
|
300
|
+
* a column. UNIQUE (event_id, user_id): a re-driven event updates, never
|
|
301
|
+
* duplicates. NO foreign keys (the subscriptions store's posture) — a
|
|
302
|
+
* wiped event simply never joins. The mailer's per-SEND audit
|
|
303
|
+
* (entity_type 'email') stands alongside; this table is the
|
|
304
|
+
* PER-RECIPIENT record. */
|
|
305
|
+
export interface NotifyDelivery {
|
|
306
|
+
id: string
|
|
307
|
+
eventId: string
|
|
308
|
+
userId: string
|
|
309
|
+
/** The resolution's strongest reason (NOTIFY_REASON_PRECEDENCE's first). */
|
|
310
|
+
reason: string
|
|
311
|
+
/** The resolved email posture (the channel split's outcome). */
|
|
312
|
+
email: NotifyChannelPreference
|
|
313
|
+
emailStatus: NotifyDeliveryStatus | null
|
|
314
|
+
/** The terminal stamp (the send / the drop); NULL while pending. */
|
|
315
|
+
emailAt: string | null
|
|
316
|
+
createdAt: string
|
|
317
|
+
}
|
|
318
|
+
|
|
272
319
|
// ── the OIDC Provider (TODO.identity/01) ─────────────────────────────
|
|
273
320
|
|
|
274
321
|
/** A registered relying party (an instance allowed to request tokens).
|
|
@@ -670,6 +717,73 @@ export interface OrgJoinRequest {
|
|
|
670
717
|
* act, never an automatic one). */
|
|
671
718
|
export type OrgMembershipState = 'invited' | 'active' | 'disabled'
|
|
672
719
|
|
|
720
|
+
// ── the per-member data cone (TODO.identity-features/09) ─────────────
|
|
721
|
+
// Every org membership carries a CONE — the org administrator's answer
|
|
722
|
+
// to "what can this member see and do" (the design: the org-wide default
|
|
723
|
+
// keeps today's behavior; 'assigned' narrows the member to the org's
|
|
724
|
+
// rows that NAME them; 'read-only' is the orthogonal modifier that
|
|
725
|
+
// refuses the member's writes). The cone lives on the membership row
|
|
726
|
+
// (org_memberships.cone, a nullable TEXT column — NULL is org-wide, so
|
|
727
|
+
// existing memberships keep their posture silently), rides the OP's
|
|
728
|
+
// claims in the active-org context, and is enforced at the platform's
|
|
729
|
+
// two choke points ONLY (the read gate and the write gate). THE
|
|
730
|
+
// INVARIANT: the cone only ever NARROWS — nothing in this machinery can
|
|
731
|
+
// grant.
|
|
732
|
+
|
|
733
|
+
/** The cone's scope: org-wide (the default — every row the org sees) or
|
|
734
|
+
* assigned (only the org's rows that NAME the member — the operator on
|
|
735
|
+
* the test run, the assignment's performer, the engagement's inquirer). */
|
|
736
|
+
export type OrgMemberConeScope = 'org-wide' | 'assigned'
|
|
737
|
+
|
|
738
|
+
/** The parsed cone (the payload shape every consumer reads). */
|
|
739
|
+
export interface OrgMemberCone {
|
|
740
|
+
scope: OrgMemberConeScope
|
|
741
|
+
/** The orthogonal modifier: the member reads per the scope but the
|
|
742
|
+
* write gate refuses them (a reviewer's posture). */
|
|
743
|
+
readOnly: boolean
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** The DEFAULT cone (a NULL column): org-wide, writable — today's
|
|
747
|
+
* behavior, kept silently by every pre-existing membership. */
|
|
748
|
+
export const ORG_MEMBER_CONE_DEFAULT: OrgMemberCone = { scope: 'org-wide', readOnly: false }
|
|
749
|
+
|
|
750
|
+
/** The FAIL-CLOSED cone: a stored value the parser cannot read narrows
|
|
751
|
+
* to the tightest posture (never a silent re-widen — the platform's
|
|
752
|
+
* standing doctrine: a malformed permission input never grants). */
|
|
753
|
+
export const ORG_MEMBER_CONE_FAIL_CLOSED: OrgMemberCone = { scope: 'assigned', readOnly: true }
|
|
754
|
+
|
|
755
|
+
/** The canonical column spellings (NULL is the default — the column
|
|
756
|
+
* stays NULL for org-wide+writable, keeping the expand-only posture
|
|
757
|
+
* clean). 'org-wide' parses but never encodes (it IS the default). */
|
|
758
|
+
export function encodeOrgMemberCone(cone: OrgMemberCone): string | null {
|
|
759
|
+
if (cone.scope === 'assigned') return cone.readOnly ? 'assigned+read-only' : 'assigned'
|
|
760
|
+
return cone.readOnly ? 'read-only' : null
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Parse the stored cone (total, never throws): NULL/empty/'org-wide'
|
|
764
|
+
* answer the default; a recognized composition parses; ANYTHING else
|
|
765
|
+
* fails CLOSED (the narrowest cone) — a hand-edited or corrupt row
|
|
766
|
+
* narrows the member, it never widens them. */
|
|
767
|
+
export function parseOrgMemberCone(raw: string | null | undefined): OrgMemberCone {
|
|
768
|
+
if (raw === null || raw === undefined) return { ...ORG_MEMBER_CONE_DEFAULT }
|
|
769
|
+
const tokens = raw.split('+').map(t => t.trim()).filter(Boolean)
|
|
770
|
+
if (tokens.length === 0) return { ...ORG_MEMBER_CONE_DEFAULT }
|
|
771
|
+
let scopeSeen: OrgMemberConeScope | null = null
|
|
772
|
+
let readOnly = false
|
|
773
|
+
for (const token of tokens) {
|
|
774
|
+
if (token === 'read-only') {
|
|
775
|
+
if (readOnly) return { ...ORG_MEMBER_CONE_FAIL_CLOSED } // a doubled modifier is not a spelling
|
|
776
|
+
readOnly = true
|
|
777
|
+
} else if (token === 'org-wide' || token === 'assigned') {
|
|
778
|
+
if (scopeSeen !== null) return { ...ORG_MEMBER_CONE_FAIL_CLOSED } // two scopes is not a spelling
|
|
779
|
+
scopeSeen = token
|
|
780
|
+
} else {
|
|
781
|
+
return { ...ORG_MEMBER_CONE_FAIL_CLOSED } // an unknown token
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return { scope: scopeSeen ?? 'org-wide', readOnly }
|
|
785
|
+
}
|
|
786
|
+
|
|
673
787
|
/** An account's membership in ONE organization (the org_memberships
|
|
674
788
|
* row): the per-org role set + the lifecycle state. The PRIMARY
|
|
675
789
|
* membership (isPrimary) is the backward-compatible one: the users
|
|
@@ -684,6 +798,10 @@ export interface OrgMembership {
|
|
|
684
798
|
/** The PER-ORG role set (JSON on the row): the roles the account holds
|
|
685
799
|
* when acting AS this org. */
|
|
686
800
|
roles: string[]
|
|
801
|
+
/** The membership's data cone (TODO.identity-features/09), parsed from
|
|
802
|
+
* the row's nullable column — the DEFAULT object when the column is
|
|
803
|
+
* NULL (org-wide, writable); never null on the payload. */
|
|
804
|
+
cone: OrgMemberCone
|
|
687
805
|
state: OrgMembershipState
|
|
688
806
|
isPrimary: boolean
|
|
689
807
|
invitedBy: string | null
|
|
@@ -693,13 +811,16 @@ export interface OrgMembership {
|
|
|
693
811
|
disabledBy: string | null
|
|
694
812
|
}
|
|
695
813
|
|
|
696
|
-
/** The effective org context: the org the account acts AS
|
|
697
|
-
* set that context carries
|
|
698
|
-
*
|
|
699
|
-
*
|
|
814
|
+
/** The effective org context: the org the account acts AS, the role
|
|
815
|
+
* set that context carries, and the context membership's data cone
|
|
816
|
+
* (TODO.identity-features/09 — NULL when NO membership row resolved:
|
|
817
|
+
* the pre-memberships dual-read and the org-free account carry no
|
|
818
|
+
* cone). The session payloads and the OP's token claims both resolve
|
|
819
|
+
* through resolveOrgContext, so the two never drift. */
|
|
700
820
|
export interface OrgContextResolution {
|
|
701
821
|
orgId: string | null
|
|
702
822
|
roles: string[]
|
|
823
|
+
cone: OrgMemberCone | null
|
|
703
824
|
}
|
|
704
825
|
|
|
705
826
|
/**
|
|
@@ -733,14 +854,16 @@ export function resolveOrgContext(
|
|
|
733
854
|
const m = context.active
|
|
734
855
|
if (m && m.orgId === context.activeOrg && m.state === 'active') {
|
|
735
856
|
const global = user.orgId ? [] : accountRoles
|
|
736
|
-
return { orgId: context.activeOrg, roles: [...new Set([...m.roles, ...global])] }
|
|
857
|
+
return { orgId: context.activeOrg, roles: [...new Set([...m.roles, ...global])], cone: m.cone }
|
|
737
858
|
}
|
|
738
859
|
}
|
|
739
|
-
if (!user.orgId) return { orgId: null, roles: accountRoles }
|
|
860
|
+
if (!user.orgId) return { orgId: null, roles: accountRoles, cone: null }
|
|
740
861
|
const p = context.primary
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
return { orgId: user.orgId, roles:
|
|
862
|
+
// The pre-memberships dual-read (no primary row) carries NO cone — the
|
|
863
|
+
// org-wide default the enforcement reads for a cone-less context.
|
|
864
|
+
if (!p) return { orgId: user.orgId, roles: accountRoles, cone: null }
|
|
865
|
+
if (p.state !== 'active') return { orgId: null, roles: [], cone: null }
|
|
866
|
+
return { orgId: user.orgId, roles: [...new Set([...accountRoles, ...p.roles])], cone: p.cone }
|
|
744
867
|
}
|
|
745
868
|
|
|
746
869
|
// ── the organization registry (TODO.identity-features/05) ────────────
|
|
@@ -1539,6 +1662,13 @@ export interface ServerStore {
|
|
|
1539
1662
|
state: OrgMembershipState,
|
|
1540
1663
|
actor?: string | null,
|
|
1541
1664
|
): Promise<OrgMembership | null>
|
|
1665
|
+
/** Set the membership's data cone (TODO.identity-features/09): the
|
|
1666
|
+
* CANONICAL column spelling (encodeOrgMemberCone's answer — NULL is
|
|
1667
|
+
* the org-wide default) or NULL to clear. The input's validation is
|
|
1668
|
+
* the ROUTE's (parse + re-encode; the store writes what it is given,
|
|
1669
|
+
* the parser's fail-closed posture backstops a bad cell). Answers
|
|
1670
|
+
* null when no membership exists. */
|
|
1671
|
+
setOrgMembershipCone(userId: string, orgId: string, cone: string | null): Promise<OrgMembership | null>
|
|
1542
1672
|
/** Remove the row — the holder declining an invitation, and the
|
|
1543
1673
|
* erasure's cleanup. (The routes refuse the PRIMARY membership: the
|
|
1544
1674
|
* primary binding moves through the account's role/org assignment,
|
|
@@ -1781,6 +1911,36 @@ export interface ServerStore {
|
|
|
1781
1911
|
done?: boolean
|
|
1782
1912
|
}): Promise<NotifyInboxState>
|
|
1783
1913
|
|
|
1914
|
+
// ── the email channel's delivery store (TODO.notify/04) ──
|
|
1915
|
+
/** The fan-out's write (the upsert on UNIQUE (event_id, user_id) — a
|
|
1916
|
+
* re-driven event updates, never duplicates). Answers the stored row. */
|
|
1917
|
+
putNotifyDelivery(input: {
|
|
1918
|
+
id: string
|
|
1919
|
+
eventId: string
|
|
1920
|
+
userId: string
|
|
1921
|
+
reason: string
|
|
1922
|
+
email: NotifyChannelPreference
|
|
1923
|
+
emailStatus: NotifyDeliveryStatus | null
|
|
1924
|
+
}): Promise<NotifyDelivery>
|
|
1925
|
+
/** The by-pair read (the idempotence leg + the proofs). */
|
|
1926
|
+
getNotifyDelivery(eventId: string, userId: string): Promise<NotifyDelivery | null>
|
|
1927
|
+
/** One event's delivery rows (the proofs; the fan-out's own log). */
|
|
1928
|
+
listNotifyDeliveriesForEvent(eventId: string): Promise<NotifyDelivery[]>
|
|
1929
|
+
/** THE DIGEST SWEEP's reads: the users holding digest-pending rows,
|
|
1930
|
+
* then one user's pending rows (created_at order — the day's story). */
|
|
1931
|
+
notifyDigestPendingUsers(): Promise<string[]>
|
|
1932
|
+
notifyDigestPendingForUser(userId: string): Promise<NotifyDelivery[]>
|
|
1933
|
+
/** THE RETRY SWEEP's read: the failed immediate rows, oldest first
|
|
1934
|
+
* (bounded — the sweep re-attempts per run, the mailer's rate limits
|
|
1935
|
+
* stand per attempt). */
|
|
1936
|
+
notifyFailedDeliveries(limit?: number): Promise<NotifyDelivery[]>
|
|
1937
|
+
/** The terminal/pending mark: the status + the stamp (email_at sets on
|
|
1938
|
+
* the terminal marks — sent/failed/rate_limited/unavailable/
|
|
1939
|
+
* digest_sent/digest_failed/digest_dropped — and CLEARS back to NULL
|
|
1940
|
+
* when a row re-enters digest_pending, so a retry's re-queue is
|
|
1941
|
+
* honest). */
|
|
1942
|
+
markNotifyDelivery(id: string, status: NotifyDeliveryStatus): Promise<void>
|
|
1943
|
+
|
|
1784
1944
|
// ── provisioning / dev support ──
|
|
1785
1945
|
/** The mutable workflow stores emptied (the dev-reset + demo reseed
|
|
1786
1946
|
* leg): entities, entity_changes, evidence_records, events, and
|