@oimlsmart/platform-server 0.1.6 → 0.1.8

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/src/store.ts CHANGED
@@ -436,6 +436,44 @@ export interface OidcKeyRow {
436
436
  retiredAt: string | null
437
437
  }
438
438
 
439
+ // ── the remembered consent grants (TODO.identity-features/12) ────────
440
+
441
+ /** A remembered consent grant (the oidc_consent_grants row): the account
442
+ * holder's "Allow", remembered per (user, client, scope set) so a repeat
443
+ * authorization the grant COVERS skips the consent page. A LIVE grant
444
+ * carries revoked_at NULL — revocation flips the stamp and the row stays
445
+ * (the audit chain's resolvable record); the account erasure removes the
446
+ * rows outright (the PAT doctrine). scope is the CANONICAL set spelling
447
+ * (normalizeOidcScopeSet) — the partial unique index keys one live row
448
+ * per (user, client, scope) triple. */
449
+ export interface OidcConsentGrant {
450
+ id: string
451
+ userId: string
452
+ clientId: string
453
+ /** The granted scope set, space-joined in the canonical spelling. */
454
+ scope: string
455
+ createdAt: string
456
+ revokedAt: string | null
457
+ }
458
+
459
+ /** The scope set's canonical spelling: split on whitespace, drop empties
460
+ * and duplicates, sort — 'profile openid' and 'openid profile' are the
461
+ * SAME set, so the (user, client, scope) triple's uniqueness holds
462
+ * honestly. */
463
+ export function normalizeOidcScopeSet(scope: string): string {
464
+ return [...new Set(scope.split(/\s+/).filter(Boolean))].sort().join(' ')
465
+ }
466
+
467
+ /** The skip check's coverage math (the authorize endpoint's rule): a live
468
+ * grant covers the request when EVERY requested scope is in the granted
469
+ * set. Both sides normalize first, so a hand-edited row still reads as a
470
+ * set — never trusted as a string match. */
471
+ export function consentGrantCovers(grantScope: string, requestedScope: string): boolean {
472
+ const granted = new Set(normalizeOidcScopeSet(grantScope).split(' ').filter(Boolean))
473
+ const requested = normalizeOidcScopeSet(requestedScope).split(' ').filter(Boolean)
474
+ return requested.length > 0 && requested.every(s => granted.has(s))
475
+ }
476
+
439
477
  // ── the upstream providers (TODO.identity/08) ────────────────────────
440
478
 
441
479
  /** An upstream identity provider the OP links + accepts (a registry
@@ -551,7 +589,11 @@ export interface OpClientRoleAssignment {
551
589
  * rows removed (passkeys, TOTP secrets, recovery codes, and the
552
590
  * account's pending ceremony state). TODO.identity-features/08 adds
553
591
  * `personalAccessTokens`: the developer-token rows (a dead account's
554
- * tokens die with it). */
592
+ * tokens die with it). TODO.identity-features/12 adds `consentGrants`:
593
+ * the remembered consent rows (a dead account's grants die with it).
594
+ * TODO.identity-features/01 adds `emails`: the additional-address rows
595
+ * (every address of the account goes — the tombstone's primary is the
596
+ * anonymized users.email). */
555
597
  export interface OpAccountErasure {
556
598
  sessions: number
557
599
  accessTokens: number
@@ -563,21 +605,29 @@ export interface OpAccountErasure {
563
605
  tokens: number
564
606
  factors: number
565
607
  personalAccessTokens: number
608
+ consentGrants: number
609
+ emails: number
566
610
  }
567
611
 
568
612
  // ── the account console (TODO.identity/06) ───────────────────────────
569
613
 
570
- /** The verify-new-email ceremony's row (the enrollment link's doctrine:
614
+ /** The verify-an-address ceremony's row (the enrollment link's doctrine:
571
615
  * a 256-bit random token backed by the D1 row; one-time, 24 h).
572
616
  * deliveredBy records the channel the link traveled: 'mailer' (sent to
573
617
  * the NEW address; completing verifies it) or 'shown' (no mailer
574
618
  * configured, the link was displayed to the signed-in holder; the change
575
- * applies but the address stays unverified, honestly). */
619
+ * applies but the address stays unverified, honestly).
620
+ * TODO.identity-features/01: kind names the ceremony — 'change' (the
621
+ * primary-address replacement; completion moves users.email) or 'add'
622
+ * (the per-address verification of an account_emails row; completion
623
+ * stamps the row's verified_at). Rows predating the kind column read
624
+ * 'change' (the migration's default). */
576
625
  export interface EmailChangeToken {
577
626
  token: string
578
627
  userId: string
579
628
  newEmail: string
580
629
  deliveredBy: 'mailer' | 'shown'
630
+ kind: 'change' | 'add'
581
631
  createdAt: string
582
632
  expiresAt: string
583
633
  consumedAt: string | null
@@ -595,6 +645,38 @@ export type CompleteEmailChangeResult =
595
645
  * (the token is burned; the change must start over). */
596
646
  | { kind: 'conflict' }
597
647
 
648
+ // ── multiple emails per account (TODO.identity-features/01) ──────────
649
+
650
+ /** One of the account's addresses. The PRIMARY is the users row's email
651
+ * (isPrimary — the OIDC `email` claim's source, never an
652
+ * account_emails row); the ADDITIONAL addresses are the account_emails
653
+ * rows. verifiedAt NULL = the mailbox is unproven: an unverified
654
+ * additional NEVER names the account (not to sign-in, not to recovery,
655
+ * never a notification's target). */
656
+ export interface AccountEmail {
657
+ userId: string
658
+ email: string
659
+ verifiedAt: string | null
660
+ isPrimary: boolean
661
+ /** Who added the row (the holder's session email, an admin's); NULL on
662
+ * the primary line (the users row carries no such provenance). */
663
+ addedBy: string | null
664
+ createdAt: string
665
+ }
666
+
667
+ /** The additional-address add's honest outcomes. */
668
+ export type AddAccountEmailResult =
669
+ /** The row landed (unverified — the verification ceremony follows). */
670
+ | 'added'
671
+ /** The account ALREADY carries the address as an additional (the add
672
+ * is an idempotent no-op; the route re-sends the verification for an
673
+ * unverified row). */
674
+ | 'present'
675
+ /** Another account holds the address (as its primary or an
676
+ * additional), or it IS this account's primary — an address names at
677
+ * most one account across the estate. */
678
+ | 'conflict'
679
+
598
680
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03)
599
681
 
600
682
  /** A registered passkey (the webauthn_credentials row). publicKeyCose is
@@ -1468,6 +1550,28 @@ export interface ServerStore {
1468
1550
  upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
1469
1551
  retireOidcKey(kid: string): Promise<void>
1470
1552
 
1553
+ // ── the remembered consent grants (TODO.identity-features/12) ──
1554
+ /** The authorize endpoint's remembered-consent read: the account's
1555
+ * LIVE grant for this client whose scope set COVERS the requested set
1556
+ * (the consentGrantCovers math over the live rows), or null — the
1557
+ * consent page shows. Both scope spellings normalize before the math. */
1558
+ getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null>
1559
+ /** The consent decision's remember (the allow): the upsert per
1560
+ * (user, client, scope) — a live triple's row refreshes its stamp (the
1561
+ * re-affirmed consent); a REVOKED triple's re-allow lands a FRESH live
1562
+ * row (the partial unique index keeps the revoked rows out of the
1563
+ * collision, so the history survives). The scope cell stores the
1564
+ * canonical spelling (normalizeOidcScopeSet). Answers the live row. */
1565
+ recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant>
1566
+ /** The account console's "apps they can access": the account's LIVE
1567
+ * grants, newest first (the revoked rows never list — the audit chain
1568
+ * carries them). */
1569
+ listConsentGrants(userId: string): Promise<OidcConsentGrant[]>
1570
+ /** The console's "Revoke access": flips revoked_at on the account's OWN
1571
+ * live row — a second revoke or another account's row answers false
1572
+ * (the PAT guard's posture). The row STAYS. */
1573
+ revokeConsentGrant(id: string, userId: string): Promise<boolean>
1574
+
1471
1575
  // ── the upstream providers (TODO.identity/08) ──
1472
1576
  /** The upstream registry (admin-managed; OP_UPSTREAM_SEED bootstraps).
1473
1577
  * Secrets are NEVER in these rows — clientSecretRef is an env name. */
@@ -1519,7 +1623,13 @@ export interface ServerStore {
1519
1623
  /** The password sign-in's lookup: the credential + the account's
1520
1624
  * active flag by email (normalized). Null = no such credential — the
1521
1625
  * route still runs one full-cost verify (the timing-shape rule,
1522
- * auth/passwords.ts). The hash never leaves the server. */
1626
+ * auth/passwords.ts). The hash never leaves the server.
1627
+ * TODO.identity-features/01: the address resolves by ANY of the
1628
+ * account's VERIFIED addresses — the primary (users.email) or a
1629
+ * proven account_emails row. An unverified additional never resolves
1630
+ * (the mailbox is unproven), and a primary owner always wins over an
1631
+ * additional row (the deterministic rule — a stray duplicate shadows,
1632
+ * never ambiguates). */
1523
1633
  getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null>
1524
1634
  /** Set/replace the account's password credential (enrollment
1525
1635
  * completion, the account page's change). */
@@ -1618,15 +1728,20 @@ export interface ServerStore {
1618
1728
  * "sign out everywhere else" action + the password change's
1619
1729
  * best-practice revocation). Answers the revoked count. */
1620
1730
  deleteOtherSessions(userId: string, keepToken: string): Promise<number>
1621
- /** Mint the verify-new-email ceremony's token. A fresh request VOIDS
1622
- * the account's earlier pending rows (only the newest link works).
1623
- * deliveredBy is stamped at request time and decides whether
1624
- * completion may verify the address. */
1731
+ /** Mint the verify-an-address ceremony's token. The void rule keeps
1732
+ * one live link per ceremony target: a 'change' request VOIDS the
1733
+ * account's earlier pending 'change' rows (only the newest change
1734
+ * link works the pre-01 doctrine); an 'add' request voids the
1735
+ * account's earlier pending 'add' rows FOR THE SAME address (other
1736
+ * addresses' links stand). deliveredBy is stamped at request time and
1737
+ * decides whether completion may verify the address. kind defaults
1738
+ * 'change'. */
1625
1739
  createEmailChangeToken(input: {
1626
1740
  token: string
1627
1741
  userId: string
1628
1742
  newEmail: string
1629
1743
  deliveredBy: 'mailer' | 'shown'
1744
+ kind?: 'change' | 'add'
1630
1745
  ttlMs: number
1631
1746
  }): Promise<EmailChangeToken>
1632
1747
  getEmailChangeToken(token: string): Promise<EmailChangeToken | null>
@@ -1634,12 +1749,51 @@ export interface ServerStore {
1634
1749
  * so the console can show it. */
1635
1750
  getPendingEmailChange(userId: string): Promise<EmailChangeToken | null>
1636
1751
  /** Complete the ceremony: consume the token ATOMICALLY (a presented
1637
- * link works exactly once, expired or not), judge the expiry, re-check
1638
- * the address's uniqueness (a conflict burns the token honestly), then
1639
- * move the account's email. verified = the token traveled by mailer
1640
- * (mailbox proven); a shown link never verifies. */
1752
+ * link works exactly once, expired or not), judge the expiry, then
1753
+ * act on the kind: 'change' re-checks the address's uniqueness across
1754
+ * BOTH address tables (a conflict burns the token honestly) and moves
1755
+ * the account's primary (users.email); 'add' stamps the
1756
+ * account_emails row's verified_at (a row removed between request and
1757
+ * completion answers 'unknown'). verified = the token traveled by
1758
+ * mailer (mailbox proven); a shown link never verifies. */
1641
1759
  completeEmailChange(token: string): Promise<CompleteEmailChangeResult>
1642
1760
 
1761
+ // ── multiple emails per account (TODO.identity-features/01) ──
1762
+ /** The account's addresses, the PRIMARY first (the users row's email +
1763
+ * its verification stamp), then the additional account_emails rows
1764
+ * (oldest first). The console's emails section and the security-mail
1765
+ * fan-out read this. */
1766
+ listAccountEmails(userId: string): Promise<AccountEmail[]>
1767
+ /** Resolve the account by ANY of its addresses (normalized): the
1768
+ * primary always names it; an additional ONLY when verified (an
1769
+ * unproven address never names the account — not to sign-in, not to
1770
+ * recovery). The primary owner wins over an additional row (the
1771
+ * deterministic rule). */
1772
+ findUserByAnyEmail(email: string): Promise<AuthUserPayload | null>
1773
+ /** Add an ADDITIONAL address (normalized lowercase; the row lands
1774
+ * UNVERIFIED — the verify-an-address ceremony's kind 'add' token
1775
+ * proves the mailbox). The tagged result names the outcome; the
1776
+ * unique index + the cross-table check make an address name at most
1777
+ * one account. */
1778
+ addAccountEmail(userId: string, email: string, addedBy?: string | null): Promise<AddAccountEmailResult>
1779
+ /** The verification ceremony's stamp on the account's OWN additional
1780
+ * row (the kind 'add' completion): verified_at flips, once (the
1781
+ * guarded update). Answers false when no such row stands. */
1782
+ markAccountEmailVerified(userId: string, email: string): Promise<boolean>
1783
+ /** Promote a VERIFIED additional to primary: the promoted address
1784
+ * becomes users.email (its verification stamp travels, so the claims
1785
+ * stay verified), and the previous primary takes the row's place in
1786
+ * account_emails with ITS stamp (it stays a verified additional —
1787
+ * sign-in by it keeps working). 'unknown' = no such additional row;
1788
+ * 'unverified' = the row stands unproven (a primary is always
1789
+ * proven). */
1790
+ setPrimaryAccountEmail(userId: string, email: string): Promise<'ok' | 'unknown' | 'unverified'>
1791
+ /** Remove an ADDITIONAL address. 'primary' = the address IS the
1792
+ * account's primary (promote another first — the primary is never
1793
+ * removed from under the holder); 'unknown' = no such additional
1794
+ * row. */
1795
+ removeAccountEmail(userId: string, email: string): Promise<'ok' | 'primary' | 'unknown'>
1796
+
1643
1797
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
1644
1798
  /** The one-time WebAuthn ceremony challenge. The challenge value IS the
1645
1799
  * key (the clientDataJSON binds it); expires_at = now + ttlMs. */