@strav/social 0.4.30 → 1.0.0-alpha.22

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/index.ts +2 -0
  3. package/src/drivers/mock_driver.ts +170 -0
  4. package/src/drivers/unsupported.ts +17 -0
  5. package/src/dto/index.ts +4 -0
  6. package/src/dto/oauth_tokens.ts +26 -0
  7. package/src/dto/social_profile.ts +37 -0
  8. package/src/facebook/facebook_config.ts +68 -0
  9. package/src/facebook/facebook_driver.ts +321 -0
  10. package/src/facebook/facebook_provider.ts +29 -0
  11. package/src/facebook/index.ts +12 -0
  12. package/src/google/google_config.ts +44 -0
  13. package/src/google/google_driver.ts +317 -0
  14. package/src/google/google_provider.ts +33 -0
  15. package/src/google/index.ts +12 -0
  16. package/src/index.ts +61 -14
  17. package/src/ledger/apply_social_account_migration.ts +66 -0
  18. package/src/ledger/index.ts +12 -0
  19. package/src/ledger/social_account.ts +32 -0
  20. package/src/ledger/social_account_repository.ts +216 -0
  21. package/src/ledger/social_account_schema.ts +75 -0
  22. package/src/line/index.ts +12 -0
  23. package/src/line/line_config.ts +47 -0
  24. package/src/line/line_driver.ts +310 -0
  25. package/src/line/line_provider.ts +34 -0
  26. package/src/pkce.ts +63 -0
  27. package/src/social_capabilities.ts +35 -0
  28. package/src/social_driver.ts +105 -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 +149 -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
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,105 @@
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
+ * Methods drivers don't support throw `ProviderUnsupportedError`
9
+ * synchronously. The driver's `capabilities` set declares the
10
+ * supported feature set — apps that branch on capability avoid
11
+ * the throw by checking first.
12
+ */
13
+
14
+ import type { OAuthTokens, SocialProfile } from './dto/index.ts'
15
+ import type { SocialCapability } from './social_capabilities.ts'
16
+
17
+ export interface AuthorizeInput {
18
+ /**
19
+ * Where the provider redirects after consent. Must match
20
+ * what's registered in the provider's developer console.
21
+ */
22
+ redirectUri: string
23
+ /**
24
+ * OAuth scope list. Drivers expose `availableScopes` for
25
+ * apps that want to render a picker; apps usually hard-code
26
+ * `['profile', 'email']` or `['openid', 'profile', 'email']`.
27
+ */
28
+ scopes?: readonly string[]
29
+ /**
30
+ * Override the CSRF state. When omitted, the driver generates
31
+ * one via `randomState()`. Apps that already have a
32
+ * session-bound nonce (and want to use it as state) pass it
33
+ * here.
34
+ */
35
+ state?: string
36
+ /**
37
+ * Override the PKCE code verifier. When omitted AND the driver
38
+ * supports/requires PKCE, the driver generates one and returns
39
+ * it on the result. Apps store the returned verifier against
40
+ * the session for the callback step.
41
+ */
42
+ codeVerifier?: string
43
+ /**
44
+ * Provider-specific extra query parameters (e.g. `prompt`,
45
+ * `access_type`, `bot_prompt` for Line). The driver merges
46
+ * these into the authorize URL after the standard params.
47
+ */
48
+ extra?: Record<string, string>
49
+ }
50
+
51
+ export interface AuthorizeResult {
52
+ /** The full URL the app redirects the customer to. */
53
+ url: string
54
+ state: string
55
+ /** Set when the driver issued / accepted a PKCE verifier. Apps persist this against the session. */
56
+ codeVerifier?: string
57
+ }
58
+
59
+ export interface ExchangeInput {
60
+ code: string
61
+ redirectUri: string
62
+ /** Pass the state value the app stored at authorize-time. The driver verifies it matches `expectedState` (which the app provides on the callback). */
63
+ state?: string
64
+ /** Expected state — `state` from the AuthorizeResult the app stored. */
65
+ expectedState?: string
66
+ /** PKCE verifier — required by drivers that declared `pkce.required`. */
67
+ codeVerifier?: string
68
+ }
69
+
70
+ export interface RefreshInput {
71
+ refreshToken: string
72
+ /** Optional narrowed scope list. Most providers ignore this. */
73
+ scopes?: readonly string[]
74
+ }
75
+
76
+ export interface SocialDriver {
77
+ /** Driver identifier — `'line'` / `'google'` / `'facebook'`. */
78
+ readonly name: string
79
+ /** App-chosen instance name (`config.social.providers[name]`). */
80
+ readonly instanceName: string
81
+ readonly capabilities: ReadonlySet<SocialCapability>
82
+ /** Provider-supported scope list. Apps render pickers from this; drivers reject unknown scopes at authorize time. */
83
+ readonly availableScopes: readonly string[]
84
+
85
+ /** Build the authorize URL + emit state / PKCE artefacts the app stores. */
86
+ authorize(input: AuthorizeInput): Promise<AuthorizeResult>
87
+
88
+ /** Exchange the callback code for tokens. Verifies state when both `state` and `expectedState` are provided. */
89
+ exchange(input: ExchangeInput): Promise<OAuthTokens>
90
+
91
+ /** Fetch the normalized user profile using a valid access token. */
92
+ profile(accessToken: string): Promise<SocialProfile>
93
+
94
+ /** Trade a refresh token for a fresh access token. Drivers without the `tokens.refresh` capability throw. */
95
+ refresh(input: RefreshInput): Promise<OAuthTokens>
96
+
97
+ /** Revoke a token. Drivers without `tokens.revoke` throw. */
98
+ revoke(token: string): Promise<void>
99
+ }
100
+
101
+ /** Factory the manager invokes per configured provider. */
102
+ export type SocialDriverFactory = (config: {
103
+ instanceName: string
104
+ config: Record<string, unknown> & { driver: string }
105
+ }) => SocialDriver
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `SocialError` hierarchy — typed wrappers for OAuth/OIDC
3
+ * failures. Provider-native errors are preserved on `.cause`
4
+ * so apps can `instanceof` the vendor exception for retry /
5
+ * recovery logic; the wrapper gives the framework a consistent
6
+ * `StravError` to render through the standard exception handler.
7
+ *
8
+ * Subclasses:
9
+ *
10
+ * - `SocialConfigError` — boot-time misconfiguration
11
+ * (missing client id / secret / redirect uri).
12
+ *
13
+ * - `UnknownProviderError` — `social.use(name)` for a name
14
+ * not configured.
15
+ *
16
+ * - `ProviderUnsupportedError` — driver doesn't implement the
17
+ * requested operation (Facebook driver lacks
18
+ * `tokens.refresh` for example).
19
+ *
20
+ * - `StateMismatchError` — `state` returned on the callback
21
+ * doesn't match what `authorize()` issued. Strong signal
22
+ * of CSRF or a misrouted callback.
23
+ *
24
+ * - `OAuthExchangeError` — provider rejected the authorization
25
+ * code (expired, already used, wrong client).
26
+ *
27
+ * - `InvalidTokenError` — provider rejected the access /
28
+ * refresh token (expired, revoked, scope-mismatched).
29
+ *
30
+ * - `SocialProviderError` — generic wrapper for vendor
31
+ * exceptions that don't map to a more specific subclass.
32
+ * `cause` preserved.
33
+ */
34
+
35
+ import { StravError } from '@strav/kernel'
36
+
37
+ export class SocialError extends StravError {
38
+ constructor(
39
+ message: string,
40
+ options: {
41
+ code?: string
42
+ status?: number
43
+ context?: Record<string, unknown>
44
+ cause?: unknown
45
+ } = {},
46
+ ) {
47
+ super(
48
+ message,
49
+ { code: options.code ?? 'social.error', status: options.status ?? 500 },
50
+ {
51
+ ...(options.context ? { context: options.context } : {}),
52
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
53
+ },
54
+ )
55
+ }
56
+ }
57
+
58
+ export class SocialConfigError extends SocialError {
59
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
60
+ super(message, {
61
+ code: 'social.config',
62
+ status: 500,
63
+ ...(options.context ? { context: options.context } : {}),
64
+ })
65
+ }
66
+ }
67
+
68
+ export class UnknownProviderError extends SocialError {
69
+ constructor(name: string, available: readonly string[]) {
70
+ super(
71
+ `Social provider "${name}" is not configured. Available: ${available.join(', ') || '<none>'}.`,
72
+ {
73
+ code: 'social.unknown_provider',
74
+ status: 400,
75
+ context: { requested: name, available },
76
+ },
77
+ )
78
+ }
79
+ }
80
+
81
+ export class ProviderUnsupportedError extends SocialError {
82
+ constructor(provider: string, operation: string, options: { reason?: string } = {}) {
83
+ const trailer = options.reason ? ` ${options.reason}` : ''
84
+ super(
85
+ `Social provider "${provider}" does not support "${operation}".${trailer}`,
86
+ {
87
+ code: 'social.provider_unsupported',
88
+ status: 400,
89
+ context: {
90
+ provider,
91
+ operation,
92
+ ...(options.reason ? { reason: options.reason } : {}),
93
+ },
94
+ },
95
+ )
96
+ }
97
+ }
98
+
99
+ export class StateMismatchError extends SocialError {
100
+ constructor(message = 'Social OAuth callback state mismatch — possible CSRF or misrouted callback.') {
101
+ super(message, { code: 'social.state_mismatch', status: 400 })
102
+ }
103
+ }
104
+
105
+ export class OAuthExchangeError extends SocialError {
106
+ constructor(
107
+ message: string,
108
+ options: { context?: Record<string, unknown>; cause?: unknown } = {},
109
+ ) {
110
+ super(message, {
111
+ code: 'social.oauth_exchange',
112
+ status: 400,
113
+ ...(options.context ? { context: options.context } : {}),
114
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
115
+ })
116
+ }
117
+ }
118
+
119
+ export class InvalidTokenError extends SocialError {
120
+ constructor(
121
+ message: string,
122
+ options: { context?: Record<string, unknown>; cause?: unknown } = {},
123
+ ) {
124
+ super(message, {
125
+ code: 'social.invalid_token',
126
+ status: 401,
127
+ ...(options.context ? { context: options.context } : {}),
128
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
129
+ })
130
+ }
131
+ }
132
+
133
+ export class SocialProviderError extends SocialError {
134
+ constructor(
135
+ message: string,
136
+ options: {
137
+ provider: string
138
+ operation: string
139
+ context?: Record<string, unknown>
140
+ cause?: unknown
141
+ status?: number
142
+ },
143
+ ) {
144
+ super(message, {
145
+ code: 'social.provider_error',
146
+ status: options.status ?? 502,
147
+ context: {
148
+ provider: options.provider,
149
+ operation: options.operation,
150
+ ...(options.context ?? {}),
151
+ },
152
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
153
+ })
154
+ }
155
+ }
@@ -1,98 +1,116 @@
1
- import { inject, Configuration, ConfigurationError } from '@strav/kernel'
2
- import { Database, toSnakeCase } from '@strav/database'
3
- import type { AbstractProvider } from './abstract_provider.ts'
4
- import type { ProviderConfig, SocialConfig } from './types.ts'
5
- import { GoogleProvider } from './providers/google_provider.ts'
6
- import { GitHubProvider } from './providers/github_provider.ts'
7
- import { DiscordProvider } from './providers/discord_provider.ts'
8
- import { FacebookProvider } from './providers/facebook_provider.ts'
9
- import { LinkedInProvider } from './providers/linkedin_provider.ts'
10
- import { LineProvider } from './providers/line_provider.ts'
1
+ /**
2
+ * `SocialManager` the facade apps use for social-login flows.
3
+ *
4
+ * Three concept clusters:
5
+ *
6
+ * - **Drivers.** Apps declare providers in
7
+ * `config.social.providers`. The manager constructs each
8
+ * driver lazily on first `use(name)` call + memoizes.
9
+ * Custom drivers register via `manager.extend(name, factory)`.
10
+ *
11
+ * - **Resource accessors** (`authorize`, `exchange`, `profile`,
12
+ * `refresh`, `revoke`) — route to the default driver. Apps
13
+ * that route by region call `social.use('asia').authorize(...)`.
14
+ *
15
+ * - **Capabilities.** `driver.capabilities` exposes the
16
+ * feature set — apps that build provider-aware UI (e.g.
17
+ * "only show refresh button if the driver supports it")
18
+ * read from there.
19
+ */
11
20
 
12
- @inject
13
- export default class SocialManager {
14
- private static _db: Database
15
- private static _config: SocialConfig
16
- private static _userFkColumn: string
17
- private static _extensions = new Map<string, (config: ProviderConfig) => AbstractProvider>()
21
+ import type {
22
+ AuthorizeInput,
23
+ AuthorizeResult,
24
+ ExchangeInput,
25
+ RefreshInput,
26
+ SocialDriver,
27
+ SocialDriverFactory,
28
+ } from './social_driver.ts'
29
+ import type { OAuthTokens, SocialProfile } from './dto/index.ts'
30
+ import {
31
+ SocialConfigError,
32
+ UnknownProviderError,
33
+ } from './social_error.ts'
34
+ import type { ProviderConfig, SocialConfig } from './types.ts'
18
35
 
19
- constructor(db: Database, config: Configuration) {
20
- SocialManager._db = db
36
+ export interface SocialManagerOptions {
37
+ config: SocialConfig
38
+ }
21
39
 
22
- const userKey = config.get('social.userKey', 'id') as string
23
- SocialManager._userFkColumn = `user_${toSnakeCase(userKey)}`
40
+ export class SocialManager {
41
+ readonly config: SocialConfig
42
+ private readonly drivers = new Map<string, SocialDriver>()
43
+ private readonly extensions = new Map<string, SocialDriverFactory>()
24
44
 
25
- SocialManager._config = {
26
- userKey,
27
- providers: config.get('social.providers', {}) as Record<string, ProviderConfig>,
45
+ constructor(options: SocialManagerOptions) {
46
+ const { config } = options
47
+ if (!config.providers[config.default]) {
48
+ throw new SocialConfigError(
49
+ `SocialManager: default provider "${config.default}" is not configured.`,
50
+ {
51
+ context: {
52
+ default: config.default,
53
+ available: Object.keys(config.providers),
54
+ },
55
+ },
56
+ )
28
57
  }
58
+ this.config = config
29
59
  }
30
60
 
31
- static get db(): Database {
32
- if (!SocialManager._db) {
33
- throw new ConfigurationError(
34
- 'SocialManager not configured. Resolve it through the container first.'
61
+ use(name?: string): SocialDriver {
62
+ const key = name ?? this.config.default
63
+ const cached = this.drivers.get(key)
64
+ if (cached) return cached
65
+
66
+ const cfg = this.config.providers[key]
67
+ if (!cfg) {
68
+ throw new UnknownProviderError(key, Object.keys(this.config.providers))
69
+ }
70
+ const ext = this.extensions.get(cfg.driver)
71
+ if (!ext) {
72
+ throw new SocialConfigError(
73
+ `SocialManager: unknown driver "${cfg.driver}" for provider "${key}". Register it via \`manager.extend("${cfg.driver}", factory)\` or install the matching adapter package.`,
74
+ { context: { driver: cfg.driver, available: [...this.extensions.keys()] } },
35
75
  )
36
76
  }
37
- return SocialManager._db
77
+ const driver = ext({
78
+ instanceName: key,
79
+ config: cfg as ProviderConfig & { driver: string },
80
+ })
81
+ this.drivers.set(key, driver)
82
+ return driver
38
83
  }
39
84
 
40
- static get config(): SocialConfig {
41
- if (!SocialManager._config) {
42
- throw new ConfigurationError(
43
- 'SocialManager not configured. Resolve it through the container first.'
44
- )
45
- }
46
- return SocialManager._config
85
+ /** Register a driver factory. Adapter packages call this from their ServiceProvider. */
86
+ extend(driverName: string, factory: SocialDriverFactory): void {
87
+ this.extensions.set(driverName, factory)
47
88
  }
48
89
 
49
- /** The FK column name on the social_account table (e.g. `user_id`, `user_uid`). */
50
- static get userFkColumn(): string {
51
- return SocialManager._userFkColumn ?? 'user_id'
90
+ /** Hand-wire a driver instance (tests / one-offs). */
91
+ useDriver(instanceName: string, driver: SocialDriver): void {
92
+ this.drivers.set(instanceName, driver)
52
93
  }
53
94
 
54
- /**
55
- * Get a fresh provider instance by name.
56
- * Returns a new instance each call because fluent methods mutate state.
57
- */
58
- static driver(name: string): AbstractProvider {
59
- const providerConfig = SocialManager._config?.providers[name]
60
- if (!providerConfig) {
61
- throw new ConfigurationError(`Social provider "${name}" is not configured.`)
62
- }
95
+ // ─── Resource accessors (route to the default driver) ────────────────
63
96
 
64
- const driverName = providerConfig.driver ?? name
97
+ authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
98
+ return this.use().authorize(input)
99
+ }
65
100
 
66
- const extension = SocialManager._extensions.get(driverName)
67
- if (extension) return extension(providerConfig)
101
+ exchange(input: ExchangeInput): Promise<OAuthTokens> {
102
+ return this.use().exchange(input)
103
+ }
68
104
 
69
- switch (driverName) {
70
- case 'google':
71
- return new GoogleProvider(providerConfig)
72
- case 'github':
73
- return new GitHubProvider(providerConfig)
74
- case 'discord':
75
- return new DiscordProvider(providerConfig)
76
- case 'facebook':
77
- return new FacebookProvider(providerConfig)
78
- case 'linkedin':
79
- return new LinkedInProvider(providerConfig)
80
- case 'line':
81
- return new LineProvider(providerConfig)
82
- default:
83
- throw new ConfigurationError(
84
- `Unknown social driver "${driverName}". Register it with SocialManager.extend().`
85
- )
86
- }
105
+ profile(accessToken: string): Promise<SocialProfile> {
106
+ return this.use().profile(accessToken)
87
107
  }
88
108
 
89
- /** Register a custom provider factory. */
90
- static extend(name: string, factory: (config: ProviderConfig) => AbstractProvider): void {
91
- SocialManager._extensions.set(name, factory)
109
+ refresh(input: RefreshInput): Promise<OAuthTokens> {
110
+ return this.use().refresh(input)
92
111
  }
93
112
 
94
- /** Clear all custom extensions (useful for testing). */
95
- static reset(): void {
96
- SocialManager._extensions.clear()
113
+ revoke(token: string): Promise<void> {
114
+ return this.use().revoke(token)
97
115
  }
98
116
  }
@@ -1,16 +1,50 @@
1
- import { ServiceProvider } from '@strav/kernel'
2
- import type { Application } from '@strav/kernel'
3
- import SocialManager from './social_manager.ts'
1
+ /**
2
+ * `SocialProvider` `ServiceProvider` that wires
3
+ * `SocialManager` into the container from `config.social`.
4
+ *
5
+ * Adapter packages register their driver factories via their
6
+ * own ServiceProvider (e.g. `LineSocialProvider`) listed AFTER
7
+ * this one in `bootstrap/providers.ts`. The adapter's
8
+ * `register()` calls `manager.extend('line', factory)`; this
9
+ * provider's `boot()` eagerly resolves the manager so config
10
+ * errors surface at boot, not on first call.
11
+ *
12
+ * Driver instances are constructed lazily on first
13
+ * `social.use(name)` call.
14
+ */
4
15
 
5
- export default class SocialProvider extends ServiceProvider {
6
- readonly name = 'social'
7
- override readonly dependencies = ['database']
16
+ import {
17
+ type Application,
18
+ ConfigRepository,
19
+ ServiceProvider,
20
+ } from '@strav/kernel'
21
+ import { SocialConfigError } from './social_error.ts'
22
+ import { SocialManager } from './social_manager.ts'
23
+ import type { SocialConfig } from './types.ts'
24
+
25
+ export class SocialProvider extends ServiceProvider {
26
+ override readonly name = 'social'
27
+ override readonly dependencies = ['config']
8
28
 
9
29
  override register(app: Application): void {
10
- app.singleton(SocialManager)
30
+ app.singleton(SocialManager, (c) => {
31
+ const raw = c.resolve(ConfigRepository).get('social') as SocialConfig | undefined
32
+ if (!raw) {
33
+ throw new SocialConfigError(
34
+ 'SocialProvider: `config.social` is missing. Add `config/social.ts` with at least one provider.',
35
+ )
36
+ }
37
+ if (!raw.providers || Object.keys(raw.providers).length === 0) {
38
+ throw new SocialConfigError(
39
+ 'SocialProvider: `config.social.providers` is empty. Configure at least one provider.',
40
+ )
41
+ }
42
+ return new SocialManager({ config: raw })
43
+ })
11
44
  }
12
45
 
13
46
  override boot(app: Application): void {
47
+ // Force-resolve so config errors surface at boot, not on first call.
14
48
  app.resolve(SocialManager)
15
49
  }
16
50
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `applyTenantedSocialAccountMigration` — DDL for the opt-in
3
+ * tenanted variant of the social-account ledger.
4
+ *
5
+ * Composite unique becomes `(tenant_id, provider,
6
+ * provider_user_id)` — the same Google account can be linked
7
+ * once per tenant. The `user_id` index serves the "all
8
+ * accounts for user" lookup.
9
+ */
10
+
11
+ import {
12
+ emitCreateTable,
13
+ type DatabaseExecutor,
14
+ type SchemaRegistry,
15
+ } from '@strav/database'
16
+ import { tenantedSocialAccountSchema } from './tenanted_social_account_schema.ts'
17
+
18
+ export interface ApplyTenantedSocialAccountMigrationOptions {
19
+ /** Required for `emitCreateTable` to resolve the tenant FK ref. */
20
+ registry: SchemaRegistry
21
+ }
22
+
23
+ export async function applyTenantedSocialAccountMigration(
24
+ db: DatabaseExecutor,
25
+ options: ApplyTenantedSocialAccountMigrationOptions,
26
+ ): Promise<void> {
27
+ const { registry } = options
28
+
29
+ await db.execute(emitCreateTable(tenantedSocialAccountSchema, { registry }).sql)
30
+
31
+ await db.execute(
32
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_social_account_tenant_identity"
33
+ ON "${tenantedSocialAccountSchema.name}" ("tenant_id", "provider", "provider_user_id")`,
34
+ )
35
+
36
+ await db.execute(
37
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_social_account_user_provider"
38
+ ON "${tenantedSocialAccountSchema.name}" ("user_id", "provider")`,
39
+ )
40
+
41
+ await db.execute(
42
+ `CREATE INDEX IF NOT EXISTS "idx_social_account_user"
43
+ ON "${tenantedSocialAccountSchema.name}" ("user_id")`,
44
+ )
45
+ }
@@ -0,0 +1,18 @@
1
+ // Public API of `@strav/social/tenanted` — the opt-in
2
+ // tenant-scoped variant of the social-account ledger.
3
+ //
4
+ // Apps that need per-tenant social accounts import from here.
5
+ // Default single-tenant apps stay on `@strav/social` and never
6
+ // pay for the extra column / RLS / `withTenant` wrapping.
7
+
8
+ export {
9
+ applyTenantedSocialAccountMigration,
10
+ type ApplyTenantedSocialAccountMigrationOptions,
11
+ } from './apply_tenanted_social_account_migration.ts'
12
+ export { TenantedSocialAccount } from './tenanted_social_account.ts'
13
+ export {
14
+ type ConnectInput,
15
+ type DisconnectInput,
16
+ TenantedSocialAccountRepository,
17
+ } from './tenanted_social_account_repository.ts'
18
+ export { tenantedSocialAccountSchema } from './tenanted_social_account_schema.ts'