@oimlsmart/platform-server 0.1.7 → 0.1.9
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/0022_account_emails.sql +45 -0
- package/package.json +1 -1
- package/src/store/d1.ts +563 -309
- package/src/store/sqlite/op-accounts-store.ts +205 -21
- package/src/store/sqlite/schema.sql +26 -0
- package/src/store/sqlite/store.ts +17 -0
- package/src/store/sqlite.ts +32 -1
- package/src/store.ts +109 -12
package/src/store.ts
CHANGED
|
@@ -590,7 +590,10 @@ export interface OpClientRoleAssignment {
|
|
|
590
590
|
* account's pending ceremony state). TODO.identity-features/08 adds
|
|
591
591
|
* `personalAccessTokens`: the developer-token rows (a dead account's
|
|
592
592
|
* tokens die with it). TODO.identity-features/12 adds `consentGrants`:
|
|
593
|
-
* the remembered consent rows (a dead account's grants die with it).
|
|
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). */
|
|
594
597
|
export interface OpAccountErasure {
|
|
595
598
|
sessions: number
|
|
596
599
|
accessTokens: number
|
|
@@ -603,21 +606,28 @@ export interface OpAccountErasure {
|
|
|
603
606
|
factors: number
|
|
604
607
|
personalAccessTokens: number
|
|
605
608
|
consentGrants: number
|
|
609
|
+
emails: number
|
|
606
610
|
}
|
|
607
611
|
|
|
608
612
|
// ── the account console (TODO.identity/06) ───────────────────────────
|
|
609
613
|
|
|
610
|
-
/** The verify-
|
|
614
|
+
/** The verify-an-address ceremony's row (the enrollment link's doctrine:
|
|
611
615
|
* a 256-bit random token backed by the D1 row; one-time, 24 h).
|
|
612
616
|
* deliveredBy records the channel the link traveled: 'mailer' (sent to
|
|
613
617
|
* the NEW address; completing verifies it) or 'shown' (no mailer
|
|
614
618
|
* configured, the link was displayed to the signed-in holder; the change
|
|
615
|
-
* 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). */
|
|
616
625
|
export interface EmailChangeToken {
|
|
617
626
|
token: string
|
|
618
627
|
userId: string
|
|
619
628
|
newEmail: string
|
|
620
629
|
deliveredBy: 'mailer' | 'shown'
|
|
630
|
+
kind: 'change' | 'add'
|
|
621
631
|
createdAt: string
|
|
622
632
|
expiresAt: string
|
|
623
633
|
consumedAt: string | null
|
|
@@ -635,6 +645,38 @@ export type CompleteEmailChangeResult =
|
|
|
635
645
|
* (the token is burned; the change must start over). */
|
|
636
646
|
| { kind: 'conflict' }
|
|
637
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
|
+
|
|
638
680
|
// ── strong authentication: the factor registry (TODO.identity-sso/02 + /03)
|
|
639
681
|
|
|
640
682
|
/** A registered passkey (the webauthn_credentials row). publicKeyCose is
|
|
@@ -1581,7 +1623,13 @@ export interface ServerStore {
|
|
|
1581
1623
|
/** The password sign-in's lookup: the credential + the account's
|
|
1582
1624
|
* active flag by email (normalized). Null = no such credential — the
|
|
1583
1625
|
* route still runs one full-cost verify (the timing-shape rule,
|
|
1584
|
-
* 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). */
|
|
1585
1633
|
getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null>
|
|
1586
1634
|
/** Set/replace the account's password credential (enrollment
|
|
1587
1635
|
* completion, the account page's change). */
|
|
@@ -1680,15 +1728,20 @@ export interface ServerStore {
|
|
|
1680
1728
|
* "sign out everywhere else" action + the password change's
|
|
1681
1729
|
* best-practice revocation). Answers the revoked count. */
|
|
1682
1730
|
deleteOtherSessions(userId: string, keepToken: string): Promise<number>
|
|
1683
|
-
/** Mint the verify-
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
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'. */
|
|
1687
1739
|
createEmailChangeToken(input: {
|
|
1688
1740
|
token: string
|
|
1689
1741
|
userId: string
|
|
1690
1742
|
newEmail: string
|
|
1691
1743
|
deliveredBy: 'mailer' | 'shown'
|
|
1744
|
+
kind?: 'change' | 'add'
|
|
1692
1745
|
ttlMs: number
|
|
1693
1746
|
}): Promise<EmailChangeToken>
|
|
1694
1747
|
getEmailChangeToken(token: string): Promise<EmailChangeToken | null>
|
|
@@ -1696,12 +1749,51 @@ export interface ServerStore {
|
|
|
1696
1749
|
* so the console can show it. */
|
|
1697
1750
|
getPendingEmailChange(userId: string): Promise<EmailChangeToken | null>
|
|
1698
1751
|
/** Complete the ceremony: consume the token ATOMICALLY (a presented
|
|
1699
|
-
* link works exactly once, expired or not), judge the expiry,
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
* (
|
|
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. */
|
|
1703
1759
|
completeEmailChange(token: string): Promise<CompleteEmailChangeResult>
|
|
1704
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
|
+
|
|
1705
1797
|
// ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
|
|
1706
1798
|
/** The one-time WebAuthn ceremony challenge. The challenge value IS the
|
|
1707
1799
|
* key (the clientDataJSON binds it); expires_at = now + ttlMs. */
|
|
@@ -1873,6 +1965,11 @@ export interface ServerStore {
|
|
|
1873
1965
|
listOrgMemberships(userId: string): Promise<OrgMembership[]>
|
|
1874
1966
|
/** One org's memberships (the per-org view), every state. */
|
|
1875
1967
|
listOrgMembers(orgId: string): Promise<OrgMembership[]>
|
|
1968
|
+
/** EVERY membership across organizations, every state (the admin
|
|
1969
|
+
* registry's org list groups it in memory — one read, never a
|
|
1970
|
+
* per-org loop of listOrgMembers). Org- then creation-ordered, so a
|
|
1971
|
+
* caller's group-by-org keeps listOrgMembers' per-org ordering. */
|
|
1972
|
+
listAllOrgMemberships(): Promise<OrgMembership[]>
|
|
1876
1973
|
getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null>
|
|
1877
1974
|
/** Create the membership — the org's admin inviting an EXISTING
|
|
1878
1975
|
* account (state 'invited': the holder accepts from the account
|