@strav/social 0.4.31 → 1.0.0-alpha.24

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 (52) hide show
  1. package/package.json +23 -16
  2. package/src/drivers/facebook/facebook_config.ts +68 -0
  3. package/src/drivers/facebook/facebook_driver.ts +321 -0
  4. package/src/drivers/facebook/facebook_provider.ts +29 -0
  5. package/src/drivers/facebook/index.ts +12 -0
  6. package/src/drivers/google/google_config.ts +44 -0
  7. package/src/drivers/google/google_driver.ts +317 -0
  8. package/src/drivers/google/google_provider.ts +33 -0
  9. package/src/drivers/google/index.ts +12 -0
  10. package/src/drivers/index.ts +2 -0
  11. package/src/drivers/line/index.ts +12 -0
  12. package/src/drivers/line/line_config.ts +47 -0
  13. package/src/drivers/line/line_driver.ts +310 -0
  14. package/src/drivers/line/line_provider.ts +34 -0
  15. package/src/drivers/mock_driver.ts +170 -0
  16. package/src/drivers/unsupported.ts +17 -0
  17. package/src/dto/index.ts +4 -0
  18. package/src/dto/oauth_tokens.ts +26 -0
  19. package/src/dto/social_profile.ts +37 -0
  20. package/src/index.ts +61 -14
  21. package/src/ledger/apply_social_account_migration.ts +66 -0
  22. package/src/ledger/index.ts +12 -0
  23. package/src/ledger/social_account.ts +32 -0
  24. package/src/ledger/social_account_repository.ts +203 -0
  25. package/src/ledger/social_account_schema.ts +75 -0
  26. package/src/pkce.ts +63 -0
  27. package/src/social_capabilities.ts +35 -0
  28. package/src/social_driver.ts +143 -0
  29. package/src/social_error.ts +155 -0
  30. package/src/social_manager.ts +92 -74
  31. package/src/social_provider.ts +41 -7
  32. package/src/tenanted/apply_tenanted_social_account_migration.ts +45 -0
  33. package/src/tenanted/index.ts +18 -0
  34. package/src/tenanted/tenanted_social_account.ts +30 -0
  35. package/src/tenanted/tenanted_social_account_repository.ts +136 -0
  36. package/src/tenanted/tenanted_social_account_schema.ts +44 -0
  37. package/src/types.ts +15 -43
  38. package/CHANGELOG.md +0 -19
  39. package/README.md +0 -78
  40. package/src/abstract_provider.ts +0 -182
  41. package/src/helpers.ts +0 -31
  42. package/src/providers/discord_provider.ts +0 -59
  43. package/src/providers/facebook_provider.ts +0 -69
  44. package/src/providers/github_provider.ts +0 -75
  45. package/src/providers/google_provider.ts +0 -51
  46. package/src/providers/line_provider.ts +0 -73
  47. package/src/providers/linkedin_provider.ts +0 -51
  48. package/src/schema.ts +0 -13
  49. package/src/social_account.ts +0 -238
  50. package/stubs/config/social.ts +0 -22
  51. package/stubs/schemas/social_account.ts +0 -13
  52. package/tsconfig.json +0 -5
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `applySocialAccountMigration` — emit DDL for the
3
+ * `social_account` table plus its composite unique constraints
4
+ * and the `user_id` lookup index.
5
+ *
6
+ * Non-tenanted by default (framework policy: multitenancy is
7
+ * opt-in). Apps that need per-tenant scoping use
8
+ * `applyTenantedSocialAccountMigration` from
9
+ * `@strav/social/tenanted` instead.
10
+ *
11
+ * Apps drop one call into their migration:
12
+ *
13
+ * ```ts
14
+ * export const migration: Migration = {
15
+ * name: '20260601000000_create_social_account',
16
+ * async up(db) {
17
+ * await applySocialAccountMigration(db, { registry })
18
+ * },
19
+ * async down(db) {
20
+ * await db.execute(emitDropTable(socialAccountSchema.name).sql)
21
+ * },
22
+ * }
23
+ * ```
24
+ */
25
+
26
+ import {
27
+ emitCreateTable,
28
+ type DatabaseExecutor,
29
+ type SchemaRegistry,
30
+ } from '@strav/database'
31
+ import { socialAccountSchema } from './social_account_schema.ts'
32
+
33
+ export interface ApplySocialAccountMigrationOptions {
34
+ /** Required for `emitCreateTable` to resolve relations. */
35
+ registry: SchemaRegistry
36
+ }
37
+
38
+ export async function applySocialAccountMigration(
39
+ db: DatabaseExecutor,
40
+ options: ApplySocialAccountMigrationOptions,
41
+ ): Promise<void> {
42
+ const { registry } = options
43
+
44
+ await db.execute(emitCreateTable(socialAccountSchema, { registry }).sql)
45
+
46
+ // Provider-identity uniqueness — one Google / Line / Facebook
47
+ // identity belongs to exactly one user. The sign-in lookup
48
+ // (`findByProviderIdentity`) leans on this index.
49
+ await db.execute(
50
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_social_account_provider_identity"
51
+ ON "${socialAccountSchema.name}" ("provider", "provider_user_id")`,
52
+ )
53
+
54
+ // Per-user-per-provider uniqueness — a single user can only
55
+ // link one account per provider.
56
+ await db.execute(
57
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_social_account_user_provider"
58
+ ON "${socialAccountSchema.name}" ("user_id", "provider")`,
59
+ )
60
+
61
+ // "All accounts for user" lookup — account-settings UI.
62
+ await db.execute(
63
+ `CREATE INDEX IF NOT EXISTS "idx_social_account_user"
64
+ ON "${socialAccountSchema.name}" ("user_id")`,
65
+ )
66
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ applySocialAccountMigration,
3
+ type ApplySocialAccountMigrationOptions,
4
+ } from './apply_social_account_migration.ts'
5
+ export { SocialAccount } from './social_account.ts'
6
+ export { socialAccountSchema } from './social_account_schema.ts'
7
+ export {
8
+ type ConnectInput,
9
+ type DisconnectInput,
10
+ SocialAccountAlreadyLinkedError,
11
+ SocialAccountRepository,
12
+ } from './social_account_repository.ts'
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `SocialAccount` — typed row of the linked-provider ledger.
3
+ *
4
+ * `@encrypt` on the token fields tells the Repository to wrap
5
+ * them through the registered `EncryptionProvider` on
6
+ * write/read. In memory they are plain strings; on disk they
7
+ * are bytea.
8
+ */
9
+
10
+ import { encrypt, Model } from '@strav/database'
11
+ import { socialAccountSchema } from './social_account_schema.ts'
12
+
13
+ export class SocialAccount extends Model {
14
+ static override readonly schema = socialAccountSchema
15
+
16
+ id!: string
17
+ user_id!: string
18
+ provider!: string
19
+ provider_user_id!: string
20
+ email!: string | null
21
+ name!: string | null
22
+ avatar_url!: string | null
23
+ locale!: string | null
24
+ @encrypt access_token!: string
25
+ @encrypt refresh_token!: string | null
26
+ @encrypt id_token!: string | null
27
+ expires_at!: Date | null
28
+ scope!: string | null
29
+ metadata!: Record<string, unknown>
30
+ created_at!: Date
31
+ updated_at!: Date
32
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `SocialAccountRepository` — domain helpers on top of the
3
+ * generic CRUD surface. The four methods apps actually use:
4
+ *
5
+ * - `connect({ userId, provider, profile, tokens })` — upsert
6
+ * by `(provider, provider_user_id)` within the tenant scope.
7
+ * Runs on every sign-in: a returning user's tokens get
8
+ * refreshed; a first-time link inserts.
9
+ *
10
+ * - `disconnect({ userId, provider })` — delete the link.
11
+ * Apps invoke this when the user unlinks a provider; for
12
+ * full token revocation, drivers' `revoke()` runs separately
13
+ * (this method doesn't reach the provider).
14
+ *
15
+ * - `findByUser(userId)` — list every linked provider for a
16
+ * user. Apps render account-settings UIs from this.
17
+ *
18
+ * - `findByProviderIdentity(provider, providerUserId)` — the
19
+ * sign-in lookup: "we just verified an OAuth identity, who
20
+ * does it belong to?" Returns the row including `user_id`
21
+ * so the caller can hydrate the app's User.
22
+ *
23
+ * Tokens are encrypted via the Model's `@encrypt` decorators —
24
+ * Repository hydration handles it transparently. Apps must have
25
+ * an `EncryptionProvider` registered; otherwise the first
26
+ * encrypt/decrypt throws `ConfigError`.
27
+ */
28
+
29
+ import { quoteIdent, Repository } from '@strav/database'
30
+ import { ulid } from '@strav/kernel'
31
+ import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
32
+ import { SocialAccount } from './social_account.ts'
33
+ import { socialAccountSchema } from './social_account_schema.ts'
34
+
35
+ export interface ConnectInput {
36
+ /** App-side user id. */
37
+ userId: string
38
+ /** Provider-instance name (matches `social.use(name)`). Distinct from `profile.provider` when one driver is wired under multiple names. */
39
+ provider: string
40
+ profile: SocialProfile
41
+ tokens: OAuthTokens
42
+ }
43
+
44
+ export interface DisconnectInput {
45
+ userId: string
46
+ provider: string
47
+ }
48
+
49
+ export class SocialAccountRepository extends Repository<SocialAccount> {
50
+ static override readonly schema = socialAccountSchema
51
+ static override readonly model = SocialAccount
52
+
53
+ /**
54
+ * Upsert a social account by `(provider, provider_user_id)`.
55
+ * Insert on first link; update tokens + cached profile fields
56
+ * on subsequent sign-ins.
57
+ *
58
+ * No tenant scoping required — the default schema is
59
+ * non-tenanted. Apps that opted into the tenanted variant
60
+ * (`@strav/social/tenanted`) wrap calls in
61
+ * `TenantManager.withTenant(...)`; that variant ships its own
62
+ * Repository.
63
+ */
64
+ async connect(input: ConnectInput): Promise<SocialAccount> {
65
+ const existing = await this.findByProviderIdentity(
66
+ input.provider,
67
+ input.profile.id,
68
+ )
69
+ const now = new Date()
70
+
71
+ if (existing) {
72
+ // Cross-user link guard: if the existing row belongs to a
73
+ // different user, refuse — the app needs to resolve the
74
+ // conflict explicitly (typically "this Google account is
75
+ // already linked to another user").
76
+ if (existing.user_id !== input.userId) {
77
+ throw new SocialAccountAlreadyLinkedError({
78
+ provider: input.provider,
79
+ providerUserId: input.profile.id,
80
+ existingUserId: existing.user_id,
81
+ attemptedUserId: input.userId,
82
+ })
83
+ }
84
+ // Same user, returning sign-in: refresh tokens + cached
85
+ // profile fields via the standard Repository.update path
86
+ // (handles `@cast` / `@encrypt` round-trips for us).
87
+ return this.update(existing, {
88
+ email: input.profile.email ?? null,
89
+ name: input.profile.name ?? null,
90
+ avatar_url: input.profile.avatarUrl ?? null,
91
+ locale: input.profile.locale ?? null,
92
+ access_token: input.tokens.accessToken,
93
+ refresh_token: input.tokens.refreshToken ?? null,
94
+ id_token: input.tokens.idToken ?? null,
95
+ expires_at: input.tokens.expiresAt ?? null,
96
+ scope: input.tokens.scope ?? null,
97
+ updated_at: now,
98
+ } as Partial<SocialAccount>)
99
+ }
100
+
101
+ return this.create({
102
+ id: ulid(),
103
+ user_id: input.userId,
104
+ provider: input.provider,
105
+ provider_user_id: input.profile.id,
106
+ email: input.profile.email ?? null,
107
+ name: input.profile.name ?? null,
108
+ avatar_url: input.profile.avatarUrl ?? null,
109
+ locale: input.profile.locale ?? null,
110
+ access_token: input.tokens.accessToken,
111
+ refresh_token: input.tokens.refreshToken ?? null,
112
+ id_token: input.tokens.idToken ?? null,
113
+ expires_at: input.tokens.expiresAt ?? null,
114
+ scope: input.tokens.scope ?? null,
115
+ metadata: {},
116
+ created_at: now,
117
+ updated_at: now,
118
+ } as Partial<SocialAccount>)
119
+ }
120
+
121
+ /** Delete the link. No-op when nothing matches. */
122
+ async disconnect(input: DisconnectInput): Promise<void> {
123
+ const table = quoteIdent(socialAccountSchema.name)
124
+ await this.db.execute(
125
+ `DELETE FROM ${table} WHERE "user_id" = $1 AND "provider" = $2`,
126
+ [input.userId, input.provider],
127
+ )
128
+ }
129
+
130
+ /** Every social account linked to one user. */
131
+ async findByUser(userId: string): Promise<SocialAccount[]> {
132
+ const table = quoteIdent(socialAccountSchema.name)
133
+ const rows = await this.db.query<Record<string, unknown>>(
134
+ `SELECT * FROM ${table} WHERE "user_id" = $1 ORDER BY "created_at"`,
135
+ [userId],
136
+ )
137
+ return Promise.all(rows.map((r) => this.hydrate(r)))
138
+ }
139
+
140
+ /** Single (user, provider) lookup. */
141
+ async findByUserAndProvider(
142
+ userId: string,
143
+ provider: string,
144
+ ): Promise<SocialAccount | null> {
145
+ const table = quoteIdent(socialAccountSchema.name)
146
+ const rows = await this.db.query<Record<string, unknown>>(
147
+ `SELECT * FROM ${table} WHERE "user_id" = $1 AND "provider" = $2 LIMIT 1`,
148
+ [userId, provider],
149
+ )
150
+ if (rows.length === 0) return null
151
+ return this.hydrate(rows[0]!)
152
+ }
153
+
154
+ /**
155
+ * The sign-in lookup: given an OAuth identity, find the user
156
+ * it belongs to. Returns the account row (which includes
157
+ * `user_id`) or `null` when no link exists.
158
+ */
159
+ async findByProviderIdentity(
160
+ provider: string,
161
+ providerUserId: string,
162
+ ): Promise<SocialAccount | null> {
163
+ const table = quoteIdent(socialAccountSchema.name)
164
+ const rows = await this.db.query<Record<string, unknown>>(
165
+ `SELECT * FROM ${table}
166
+ WHERE "provider" = $1 AND "provider_user_id" = $2
167
+ LIMIT 1`,
168
+ [provider, providerUserId],
169
+ )
170
+ if (rows.length === 0) return null
171
+ return this.hydrate(rows[0]!)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Thrown when an OAuth identity is already linked to a DIFFERENT
177
+ * user than the caller is trying to attach it to. Apps catch this
178
+ * and surface a UI ("this Google account is already linked to
179
+ * <other_email>"). The framework refuses to silently move the
180
+ * link or fork the row.
181
+ */
182
+ export class SocialAccountAlreadyLinkedError extends Error {
183
+ readonly provider: string
184
+ readonly providerUserId: string
185
+ readonly existingUserId: string
186
+ readonly attemptedUserId: string
187
+
188
+ constructor(info: {
189
+ provider: string
190
+ providerUserId: string
191
+ existingUserId: string
192
+ attemptedUserId: string
193
+ }) {
194
+ super(
195
+ `SocialAccount: provider "${info.provider}" identity "${info.providerUserId}" is already linked to user "${info.existingUserId}"; refusing to relink to "${info.attemptedUserId}".`,
196
+ )
197
+ this.name = 'SocialAccountAlreadyLinkedError'
198
+ this.provider = info.provider
199
+ this.providerUserId = info.providerUserId
200
+ this.existingUserId = info.existingUserId
201
+ this.attemptedUserId = info.attemptedUserId
202
+ }
203
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `socialAccountSchema` — ledger of provider identities linked
3
+ * to app users. **Non-tenanted by default** (framework policy:
4
+ * multitenancy is opt-in). Apps that need per-tenant scoping
5
+ * import `tenantedSocialAccountSchema` from
6
+ * `@strav/social/tenanted` instead.
7
+ *
8
+ * Natural key is `(provider, provider_user_id)` — a given
9
+ * Google / Line / Facebook identity belongs to exactly one
10
+ * user. Composite uniqueness lives in the migration (the schema
11
+ * builder only exposes per-column `.unique()`).
12
+ *
13
+ * Tokens are encrypted-at-rest via `@strav/database`'s
14
+ * `t.encrypted(...)` column kind + `@encrypt` decorator on the
15
+ * Model. Apps must have an `EncryptionProvider` registered on
16
+ * the kernel container; otherwise the first repository call
17
+ * throws `ConfigError` at runtime.
18
+ *
19
+ * Why store tokens here at all: many apps need long-term
20
+ * offline access (Google `access_type=offline`) or to revoke
21
+ * later. Apps that only need "did this user sign in via X"
22
+ * set the encrypted columns to a sentinel via the Repository's
23
+ * upsert path and discard the real tokens after first use.
24
+ *
25
+ * Columns:
26
+ *
27
+ * - `id` ULID PK.
28
+ * - `user_id` App-side user reference. Free-form
29
+ * string so apps with ULID / int / uuid
30
+ * PKs all fit.
31
+ * - `provider` Driver identifier (`'line'` /
32
+ * `'google'` / `'facebook'` / custom).
33
+ * - `provider_user_id` Provider-native subject id (Google
34
+ * `sub`, Line `userId`, Facebook `id`).
35
+ * - `email` Last known email — cached for app
36
+ * UI; canonical lookups go to the user.
37
+ * - `name` Last known display name.
38
+ * - `avatar_url` Last known avatar URL.
39
+ * - `locale` Last known locale (where the provider
40
+ * gives one; Line doesn't).
41
+ * - `access_token` Encrypted-at-rest.
42
+ * - `refresh_token` Encrypted-at-rest. Nullable (Facebook
43
+ * doesn't issue them).
44
+ * - `id_token` Encrypted-at-rest. Nullable (OIDC
45
+ * providers only).
46
+ * - `expires_at` When the access token expires.
47
+ * - `scope` Space-separated granted scope string.
48
+ * - `metadata` Free-form jsonb (provider extras).
49
+ * - `created_at` / `updated_at`
50
+ */
51
+
52
+ import { Archetype, defineSchema } from '@strav/database'
53
+
54
+ export const socialAccountSchema = defineSchema(
55
+ 'social_account',
56
+ Archetype.Entity,
57
+ (t) => {
58
+ t.id()
59
+ t.string('user_id').max(64).notNull()
60
+ t.string('provider').max(64).notNull()
61
+ t.string('provider_user_id').max(255).notNull()
62
+ t.string('email').max(320).nullable()
63
+ t.string('name').max(255).nullable()
64
+ t.string('avatar_url').max(1024).nullable()
65
+ t.string('locale').max(16).nullable()
66
+ t.encrypted('access_token').notNull()
67
+ t.encrypted('refresh_token').nullable()
68
+ t.encrypted('id_token').nullable()
69
+ t.timestamp('expires_at').nullable()
70
+ t.string('scope').max(512).nullable()
71
+ t.json('metadata').notNull().default({})
72
+ t.timestamp('created_at').notNull()
73
+ t.timestamp('updated_at').notNull()
74
+ },
75
+ )
package/src/pkce.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * PKCE (Proof Key for Code Exchange) helpers — RFC 7636.
3
+ *
4
+ * For public clients (mobile, SPA, even server-side apps that
5
+ * can't keep a "client secret" truly secret), PKCE makes the
6
+ * authorization code worthless to an attacker who intercepts it
7
+ * mid-flight. Google requires PKCE on all new flows; Line supports
8
+ * it; Facebook ignores it.
9
+ *
10
+ * Flow:
11
+ *
12
+ * 1. `randomCodeVerifier()` → high-entropy random string.
13
+ * Apps store this against the user's session for the
14
+ * callback step.
15
+ * 2. `codeChallengeFor(verifier)` → base64url(sha256(verifier)).
16
+ * Drivers include this on the authorize URL as
17
+ * `code_challenge` + `code_challenge_method=S256`.
18
+ * 3. On callback, apps pass the stored verifier into
19
+ * `driver.exchange({...codeVerifier})`. The provider
20
+ * hashes it again and rejects the exchange if the hashes
21
+ * don't match.
22
+ *
23
+ * Plain (S256-only) implementation — we never emit `method=plain`.
24
+ */
25
+
26
+ const VERIFIER_LENGTH = 64 // RFC allows 43–128; 64 is comfortably above floor.
27
+
28
+ /**
29
+ * Cryptographically-strong random verifier (URL-safe alphabet).
30
+ * 64 chars at 6 bits/char ≈ 384 bits of entropy.
31
+ */
32
+ export function randomCodeVerifier(): string {
33
+ const bytes = new Uint8Array(VERIFIER_LENGTH)
34
+ crypto.getRandomValues(bytes)
35
+ return base64UrlEncode(bytes).slice(0, VERIFIER_LENGTH)
36
+ }
37
+
38
+ /** SHA-256 → base64url. The challenge the provider stores until callback. */
39
+ export async function codeChallengeFor(verifier: string): Promise<string> {
40
+ const buf = new TextEncoder().encode(verifier)
41
+ const hash = await crypto.subtle.digest('SHA-256', buf)
42
+ return base64UrlEncode(new Uint8Array(hash))
43
+ }
44
+
45
+ /**
46
+ * Random state — opaque CSRF token apps include on the
47
+ * authorize URL and verify on the callback. Drivers expose the
48
+ * verification helper (`assertStateMatches`) so apps don't
49
+ * have to roll the comparison themselves.
50
+ */
51
+ export function randomState(): string {
52
+ const bytes = new Uint8Array(32)
53
+ crypto.getRandomValues(bytes)
54
+ return base64UrlEncode(bytes)
55
+ }
56
+
57
+ function base64UrlEncode(bytes: Uint8Array): string {
58
+ // btoa needs a binary string; Bun + browsers + Node 18+ all
59
+ // handle this idiom identically.
60
+ let s = ''
61
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!)
62
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
63
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `SocialCapability` — feature flags every driver declares.
3
+ *
4
+ * Apps that build account-connect UI check these to gate buttons
5
+ * / scopes / refresh-token flows. Drivers omit a flag when they
6
+ * can't fulfil it faithfully — partial / surprising behaviour is
7
+ * worse than `ProviderUnsupportedError`.
8
+ *
9
+ * Granularity is intentionally fine: e.g. Facebook supports
10
+ * `tokens.refresh` only for long-lived tokens issued by the Pages
11
+ * API path, so v1 marks it unsupported; Line supports it
12
+ * uniformly.
13
+ */
14
+
15
+ export type SocialCapability =
16
+ // OIDC vs plain OAuth2
17
+ | 'openid' // returns an id_token + nonce flow
18
+ | 'pkce.support' // accepts PKCE (codeChallenge / codeVerifier)
19
+ | 'pkce.required' // mandates PKCE (Google)
20
+ // Profile data we can normalize
21
+ | 'profile.id'
22
+ | 'profile.email'
23
+ | 'profile.emailVerified'
24
+ | 'profile.name'
25
+ | 'profile.avatar'
26
+ | 'profile.locale'
27
+ // Token operations
28
+ | 'tokens.exchange'
29
+ | 'tokens.refresh'
30
+ | 'tokens.revoke'
31
+ | 'tokens.introspect'
32
+ // Scopes — each driver exposes its supported list separately
33
+ // via `driver.availableScopes`, but the flag here gates
34
+ // "scope picker UI" generation.
35
+ | 'scopes.discoverable'
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `SocialDriver` — the driver contract every adapter implements.
3
+ *
4
+ * One driver represents a configured provider instance
5
+ * (`config.social.providers[name]`). The manager holds one
6
+ * driver per configured name and routes calls into it.
7
+ *
8
+ * ## Capability gating — the "honest LSP violation" pattern
9
+ *
10
+ * Strictly speaking, the interface below violates Liskov substitution
11
+ * because `FacebookSocialDriver.refresh()` throws synchronously instead
12
+ * of returning `Promise<OAuthTokens>` (Facebook doesn't issue refresh
13
+ * tokens). Same shape for any future driver that lacks an operation
14
+ * its interface declares.
15
+ *
16
+ * The framework prefers this trade-off over the alternative (every
17
+ * provider gets its own narrowed sub-interface, every app does a
18
+ * `if (isRefreshable(driver))` narrowing dance) for three reasons:
19
+ *
20
+ * 1. **Discoverability**: apps see ONE `SocialDriver` interface
21
+ * with all the operations a sign-in flow needs. They learn the
22
+ * surface once, not per-adapter.
23
+ *
24
+ * 2. **Capability flags are the typed truth**: each driver
25
+ * declares `capabilities: ReadonlySet<SocialCapability>`. Apps
26
+ * that care about portability check the flag and branch — the
27
+ * `unsupported` throw becomes the safety net for callers who
28
+ * forgot, not the primary API.
29
+ *
30
+ * 3. **Failures are loud and synchronous**. Drivers use the
31
+ * `unsupported(provider, op, reason?)` helper so the throw
32
+ * happens on the function call, NOT after a network round-trip
33
+ * had a chance to bill the user / consume rate limit. Apps fail
34
+ * fast.
35
+ *
36
+ * Apps that want compile-time safety against unsupported ops wrap the
37
+ * driver themselves:
38
+ *
39
+ * ```ts
40
+ * function refreshableDriver(d: SocialDriver) {
41
+ * if (!d.capabilities.has('tokens.refresh')) return null
42
+ * return d // narrowed by convention; refresh() is now safe to call.
43
+ * }
44
+ * ```
45
+ *
46
+ * A split into `SocialDriver` + `RefreshableSocialDriver` + ... may
47
+ * land later (`docs/code-quality.md` action item #4) — but doing so
48
+ * carries the manager's `use(name): SocialDriver` return type through
49
+ * a wider refactor. Until then, the capability set IS the contract.
50
+ */
51
+
52
+ import type { OAuthTokens, SocialProfile } from './dto/index.ts'
53
+ import type { SocialCapability } from './social_capabilities.ts'
54
+
55
+ export interface AuthorizeInput {
56
+ /**
57
+ * Where the provider redirects after consent. Must match
58
+ * what's registered in the provider's developer console.
59
+ */
60
+ redirectUri: string
61
+ /**
62
+ * OAuth scope list. Drivers expose `availableScopes` for
63
+ * apps that want to render a picker; apps usually hard-code
64
+ * `['profile', 'email']` or `['openid', 'profile', 'email']`.
65
+ */
66
+ scopes?: readonly string[]
67
+ /**
68
+ * Override the CSRF state. When omitted, the driver generates
69
+ * one via `randomState()`. Apps that already have a
70
+ * session-bound nonce (and want to use it as state) pass it
71
+ * here.
72
+ */
73
+ state?: string
74
+ /**
75
+ * Override the PKCE code verifier. When omitted AND the driver
76
+ * supports/requires PKCE, the driver generates one and returns
77
+ * it on the result. Apps store the returned verifier against
78
+ * the session for the callback step.
79
+ */
80
+ codeVerifier?: string
81
+ /**
82
+ * Provider-specific extra query parameters (e.g. `prompt`,
83
+ * `access_type`, `bot_prompt` for Line). The driver merges
84
+ * these into the authorize URL after the standard params.
85
+ */
86
+ extra?: Record<string, string>
87
+ }
88
+
89
+ export interface AuthorizeResult {
90
+ /** The full URL the app redirects the customer to. */
91
+ url: string
92
+ state: string
93
+ /** Set when the driver issued / accepted a PKCE verifier. Apps persist this against the session. */
94
+ codeVerifier?: string
95
+ }
96
+
97
+ export interface ExchangeInput {
98
+ code: string
99
+ redirectUri: string
100
+ /** Pass the state value the app stored at authorize-time. The driver verifies it matches `expectedState` (which the app provides on the callback). */
101
+ state?: string
102
+ /** Expected state — `state` from the AuthorizeResult the app stored. */
103
+ expectedState?: string
104
+ /** PKCE verifier — required by drivers that declared `pkce.required`. */
105
+ codeVerifier?: string
106
+ }
107
+
108
+ export interface RefreshInput {
109
+ refreshToken: string
110
+ /** Optional narrowed scope list. Most providers ignore this. */
111
+ scopes?: readonly string[]
112
+ }
113
+
114
+ export interface SocialDriver {
115
+ /** Driver identifier — `'line'` / `'google'` / `'facebook'`. */
116
+ readonly name: string
117
+ /** App-chosen instance name (`config.social.providers[name]`). */
118
+ readonly instanceName: string
119
+ readonly capabilities: ReadonlySet<SocialCapability>
120
+ /** Provider-supported scope list. Apps render pickers from this; drivers reject unknown scopes at authorize time. */
121
+ readonly availableScopes: readonly string[]
122
+
123
+ /** Build the authorize URL + emit state / PKCE artefacts the app stores. */
124
+ authorize(input: AuthorizeInput): Promise<AuthorizeResult>
125
+
126
+ /** Exchange the callback code for tokens. Verifies state when both `state` and `expectedState` are provided. */
127
+ exchange(input: ExchangeInput): Promise<OAuthTokens>
128
+
129
+ /** Fetch the normalized user profile using a valid access token. */
130
+ profile(accessToken: string): Promise<SocialProfile>
131
+
132
+ /** Trade a refresh token for a fresh access token. Drivers without the `tokens.refresh` capability throw. */
133
+ refresh(input: RefreshInput): Promise<OAuthTokens>
134
+
135
+ /** Revoke a token. Drivers without `tokens.revoke` throw. */
136
+ revoke(token: string): Promise<void>
137
+ }
138
+
139
+ /** Factory the manager invokes per configured provider. */
140
+ export type SocialDriverFactory = (config: {
141
+ instanceName: string
142
+ config: Record<string, unknown> & { driver: string }
143
+ }) => SocialDriver