@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,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'
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `TenantedSocialAccount` — typed row of the opt-in tenanted
3
+ * ledger. Identical to `SocialAccount` except its `static schema`
4
+ * points at the tenanted variant, so the Repository runs against
5
+ * the right DDL + RLS policy.
6
+ */
7
+
8
+ import { encrypt, Model } from '@strav/database'
9
+ import { tenantedSocialAccountSchema } from './tenanted_social_account_schema.ts'
10
+
11
+ export class TenantedSocialAccount extends Model {
12
+ static override readonly schema = tenantedSocialAccountSchema
13
+
14
+ id!: string
15
+ user_id!: string
16
+ provider!: string
17
+ provider_user_id!: string
18
+ email!: string | null
19
+ name!: string | null
20
+ avatar_url!: string | null
21
+ locale!: string | null
22
+ @encrypt access_token!: string
23
+ @encrypt refresh_token!: string | null
24
+ @encrypt id_token!: string | null
25
+ expires_at!: Date | null
26
+ scope!: string | null
27
+ metadata!: Record<string, unknown>
28
+ created_at!: Date
29
+ updated_at!: Date
30
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `TenantedSocialAccountRepository` — same surface as
3
+ * `SocialAccountRepository`, scoped to the tenanted schema.
4
+ * Callers MUST be inside a `TenantManager.withTenant(...)`
5
+ * scope; the INSERT relies on the session's `app.tenant_id`
6
+ * setting (RLS).
7
+ *
8
+ * The implementation deliberately mirrors the non-tenanted
9
+ * Repository line-for-line — minor code duplication is worth
10
+ * it to keep both variants narrowly scoped + avoid runtime
11
+ * branching on a tenancy flag.
12
+ */
13
+
14
+ import { quoteIdent, Repository } from '@strav/database'
15
+ import { ulid } from '@strav/kernel'
16
+ import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
17
+ import { SocialAccountAlreadyLinkedError } from '../ledger/social_account_repository.ts'
18
+ import { TenantedSocialAccount } from './tenanted_social_account.ts'
19
+ import { tenantedSocialAccountSchema } from './tenanted_social_account_schema.ts'
20
+
21
+ export interface ConnectInput {
22
+ userId: string
23
+ provider: string
24
+ profile: SocialProfile
25
+ tokens: OAuthTokens
26
+ }
27
+
28
+ export interface DisconnectInput {
29
+ userId: string
30
+ provider: string
31
+ }
32
+
33
+ export class TenantedSocialAccountRepository extends Repository<TenantedSocialAccount> {
34
+ static override readonly schema = tenantedSocialAccountSchema
35
+ static override readonly model = TenantedSocialAccount
36
+
37
+ async connect(input: ConnectInput): Promise<TenantedSocialAccount> {
38
+ const existing = await this.findByProviderIdentity(
39
+ input.provider,
40
+ input.profile.id,
41
+ )
42
+ const now = new Date()
43
+
44
+ if (existing) {
45
+ if (existing.user_id !== input.userId) {
46
+ throw new SocialAccountAlreadyLinkedError({
47
+ provider: input.provider,
48
+ providerUserId: input.profile.id,
49
+ existingUserId: existing.user_id,
50
+ attemptedUserId: input.userId,
51
+ })
52
+ }
53
+ return this.update(existing, {
54
+ email: input.profile.email ?? null,
55
+ name: input.profile.name ?? null,
56
+ avatar_url: input.profile.avatarUrl ?? null,
57
+ locale: input.profile.locale ?? null,
58
+ access_token: input.tokens.accessToken,
59
+ refresh_token: input.tokens.refreshToken ?? null,
60
+ id_token: input.tokens.idToken ?? null,
61
+ expires_at: input.tokens.expiresAt ?? null,
62
+ scope: input.tokens.scope ?? null,
63
+ updated_at: now,
64
+ } as Partial<TenantedSocialAccount>)
65
+ }
66
+
67
+ return this.create({
68
+ id: ulid(),
69
+ user_id: input.userId,
70
+ provider: input.provider,
71
+ provider_user_id: input.profile.id,
72
+ email: input.profile.email ?? null,
73
+ name: input.profile.name ?? null,
74
+ avatar_url: input.profile.avatarUrl ?? null,
75
+ locale: input.profile.locale ?? null,
76
+ access_token: input.tokens.accessToken,
77
+ refresh_token: input.tokens.refreshToken ?? null,
78
+ id_token: input.tokens.idToken ?? null,
79
+ expires_at: input.tokens.expiresAt ?? null,
80
+ scope: input.tokens.scope ?? null,
81
+ metadata: {},
82
+ created_at: now,
83
+ updated_at: now,
84
+ } as Partial<TenantedSocialAccount>)
85
+ }
86
+
87
+ async disconnect(input: DisconnectInput): Promise<void> {
88
+ const table = quoteIdent(tenantedSocialAccountSchema.name)
89
+ await this.db.execute(
90
+ `DELETE FROM ${table} WHERE "user_id" = $1 AND "provider" = $2`,
91
+ [input.userId, input.provider],
92
+ )
93
+ }
94
+
95
+ async findByUser(userId: string): Promise<TenantedSocialAccount[]> {
96
+ const table = quoteIdent(tenantedSocialAccountSchema.name)
97
+ const rows = await this.db.query<Record<string, unknown>>(
98
+ `SELECT * FROM ${table} WHERE "user_id" = $1 ORDER BY "created_at"`,
99
+ [userId],
100
+ )
101
+ return Promise.all(rows.map((r) => this.hydrate(r)))
102
+ }
103
+
104
+ async findByUserAndProvider(
105
+ userId: string,
106
+ provider: string,
107
+ ): Promise<TenantedSocialAccount | null> {
108
+ const table = quoteIdent(tenantedSocialAccountSchema.name)
109
+ const rows = await this.db.query<Record<string, unknown>>(
110
+ `SELECT * FROM ${table} WHERE "user_id" = $1 AND "provider" = $2 LIMIT 1`,
111
+ [userId, provider],
112
+ )
113
+ if (rows.length === 0) return null
114
+ return this.hydrate(rows[0]!)
115
+ }
116
+
117
+ /**
118
+ * Sign-in lookup. Scoped by RLS to the current tenant — the
119
+ * same provider identity can exist in two tenants without
120
+ * collision; this query only sees the one in scope.
121
+ */
122
+ async findByProviderIdentity(
123
+ provider: string,
124
+ providerUserId: string,
125
+ ): Promise<TenantedSocialAccount | null> {
126
+ const table = quoteIdent(tenantedSocialAccountSchema.name)
127
+ const rows = await this.db.query<Record<string, unknown>>(
128
+ `SELECT * FROM ${table}
129
+ WHERE "provider" = $1 AND "provider_user_id" = $2
130
+ LIMIT 1`,
131
+ [provider, providerUserId],
132
+ )
133
+ if (rows.length === 0) return null
134
+ return this.hydrate(rows[0]!)
135
+ }
136
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `tenantedSocialAccountSchema` — opt-in tenant-scoped variant
3
+ * of the social-account ledger. Imported from
4
+ * `@strav/social/tenanted` so apps that don't need
5
+ * multitenancy don't pay for it.
6
+ *
7
+ * Same columns as the default `socialAccountSchema`, with
8
+ * `tenanted: true` so `@strav/database` injects the
9
+ * `tenant_id` FK + RLS policy. Composite unique becomes
10
+ * `(tenant_id, provider, provider_user_id)` — the same Google
11
+ * account can be linked across distinct tenants (one per
12
+ * tenant), but only once per tenant.
13
+ *
14
+ * Apps register this schema instead of (or alongside, under a
15
+ * different name) the default. The matching `Model` +
16
+ * `Repository` + `applyTenantedSocialAccountMigration` ship
17
+ * here too so the wiring stays consistent.
18
+ */
19
+
20
+ import { Archetype, defineSchema } from '@strav/database'
21
+
22
+ export const tenantedSocialAccountSchema = defineSchema(
23
+ 'social_account',
24
+ Archetype.Entity,
25
+ (t) => {
26
+ t.id()
27
+ t.string('user_id').max(64).notNull()
28
+ t.string('provider').max(64).notNull()
29
+ t.string('provider_user_id').max(255).notNull()
30
+ t.string('email').max(320).nullable()
31
+ t.string('name').max(255).nullable()
32
+ t.string('avatar_url').max(1024).nullable()
33
+ t.string('locale').max(16).nullable()
34
+ t.encrypted('access_token').notNull()
35
+ t.encrypted('refresh_token').nullable()
36
+ t.encrypted('id_token').nullable()
37
+ t.timestamp('expires_at').nullable()
38
+ t.string('scope').max(512).nullable()
39
+ t.json('metadata').notNull().default({})
40
+ t.timestamp('created_at').notNull()
41
+ t.timestamp('updated_at').notNull()
42
+ },
43
+ { tenanted: true },
44
+ )