@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
@@ -0,0 +1,317 @@
1
+ /**
2
+ * `GoogleSocialDriver` — Google Sign-In (OAuth 2.0 + OIDC) via
3
+ * the standard Web application client type.
4
+ *
5
+ * Notes worth knowing:
6
+ *
7
+ * - **Scopes**: `openid` (id_token), `profile`, `email`.
8
+ * Google accepts both short (`'email'`) and fully-qualified
9
+ * (`'https://www.googleapis.com/auth/userinfo.email'`) forms;
10
+ * we use the short forms.
11
+ *
12
+ * - **PKCE**: supported. Mandatory for `installed` / SPA
13
+ * client types; optional for `Web application`. The driver
14
+ * defaults PKCE on as defence in depth (matches Line + the
15
+ * OAuth 2.1 trajectory).
16
+ *
17
+ * - **Refresh tokens**: Google only issues `refresh_token` on
18
+ * `access_type=offline`. The driver defaults to including
19
+ * it. A `refresh_token` is returned ONLY on the user's
20
+ * first consent unless `prompt=consent` forces re-consent.
21
+ * Apps re-establishing offline access after revocation pass
22
+ * `extra: { prompt: 'consent' }`.
23
+ *
24
+ * - **id_token**: returned when `openid` is in the requested
25
+ * scope set. Decoding helper provided for `email` extraction,
26
+ * mirroring `emailFromLineIdToken`. Signature verification
27
+ * deferred to apps (use Google's `tokeninfo` endpoint or a
28
+ * JWT library + Google's JWKS).
29
+ *
30
+ * - **`hd` (hosted domain)**: Google Workspace constraint —
31
+ * pass `extra: { hd: 'example.com' }` on authorize to limit
32
+ * consent to one Workspace domain.
33
+ */
34
+
35
+ import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
36
+ import type { SocialCapability } from '../social_capabilities.ts'
37
+ import type {
38
+ AuthorizeInput,
39
+ AuthorizeResult,
40
+ ExchangeInput,
41
+ RefreshInput,
42
+ SocialDriver,
43
+ } from '../social_driver.ts'
44
+ import {
45
+ InvalidTokenError,
46
+ OAuthExchangeError,
47
+ SocialProviderError,
48
+ StateMismatchError,
49
+ } from '../social_error.ts'
50
+ import { codeChallengeFor, randomCodeVerifier, randomState } from '../pkce.ts'
51
+ import { GOOGLE_ENDPOINTS, type GoogleProviderConfig } from './google_config.ts'
52
+
53
+ const PROVIDER = 'google'
54
+
55
+ const CAPS: readonly SocialCapability[] = [
56
+ 'openid', 'pkce.support',
57
+ 'profile.id', 'profile.email', 'profile.emailVerified',
58
+ 'profile.name', 'profile.avatar', 'profile.locale',
59
+ 'tokens.exchange', 'tokens.refresh', 'tokens.revoke', 'tokens.introspect',
60
+ 'scopes.discoverable',
61
+ ]
62
+
63
+ const SCOPES: readonly string[] = ['openid', 'profile', 'email']
64
+
65
+ export interface GoogleDriverOptions {
66
+ instanceName: string
67
+ config: GoogleProviderConfig
68
+ }
69
+
70
+ interface TokenResponse {
71
+ access_token: string
72
+ expires_in: number
73
+ id_token?: string
74
+ refresh_token?: string
75
+ scope?: string
76
+ token_type: string
77
+ }
78
+
79
+ interface UserInfoResponse {
80
+ sub: string
81
+ email?: string
82
+ email_verified?: boolean
83
+ name?: string
84
+ given_name?: string
85
+ family_name?: string
86
+ picture?: string
87
+ locale?: string
88
+ }
89
+
90
+ interface JwtPayload {
91
+ email?: string
92
+ email_verified?: boolean
93
+ sub?: string
94
+ [k: string]: unknown
95
+ }
96
+
97
+ export class GoogleSocialDriver implements SocialDriver {
98
+ readonly name = PROVIDER
99
+ readonly instanceName: string
100
+ readonly capabilities: ReadonlySet<SocialCapability> = new Set(CAPS)
101
+ readonly availableScopes = SCOPES
102
+
103
+ private readonly config: GoogleProviderConfig
104
+ private readonly fetchFn: typeof fetch
105
+ private readonly endpoints: {
106
+ authorize: string
107
+ token: string
108
+ userInfo: string
109
+ revoke: string
110
+ tokenInfo: string
111
+ }
112
+
113
+ constructor(options: GoogleDriverOptions) {
114
+ this.instanceName = options.instanceName
115
+ this.config = options.config
116
+ this.fetchFn = options.config.fetch ?? fetch
117
+ this.endpoints = { ...GOOGLE_ENDPOINTS, ...(options.config.endpoints ?? {}) }
118
+ }
119
+
120
+ async authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
121
+ const state = input.state ?? randomState()
122
+ const optOut = input.extra?.no_pkce === '1'
123
+ const codeVerifier = optOut
124
+ ? undefined
125
+ : input.codeVerifier ?? randomCodeVerifier()
126
+ const challenge = codeVerifier ? await codeChallengeFor(codeVerifier) : undefined
127
+
128
+ const scopes = input.scopes ?? ['openid', 'profile', 'email']
129
+ const offline = this.config.offlineAccess !== false
130
+
131
+ const params = new URLSearchParams({
132
+ response_type: 'code',
133
+ client_id: this.config.clientId,
134
+ redirect_uri: input.redirectUri,
135
+ scope: scopes.join(' '),
136
+ state,
137
+ ...(challenge ? { code_challenge: challenge, code_challenge_method: 'S256' } : {}),
138
+ ...(offline ? { access_type: 'offline' } : {}),
139
+ // `include_granted_scopes=true` makes Google merge previous
140
+ // grants rather than replace — apps that progressively
141
+ // request scopes appreciate it; safe-by-default.
142
+ include_granted_scopes: 'true',
143
+ ...(input.extra ?? {}),
144
+ })
145
+ params.delete('no_pkce')
146
+
147
+ return {
148
+ url: `${this.endpoints.authorize}?${params.toString()}`,
149
+ state,
150
+ ...(codeVerifier ? { codeVerifier } : {}),
151
+ }
152
+ }
153
+
154
+ async exchange(input: ExchangeInput): Promise<OAuthTokens> {
155
+ if (input.expectedState !== undefined && input.state !== input.expectedState) {
156
+ throw new StateMismatchError()
157
+ }
158
+ const body = new URLSearchParams({
159
+ grant_type: 'authorization_code',
160
+ code: input.code,
161
+ redirect_uri: input.redirectUri,
162
+ client_id: this.config.clientId,
163
+ client_secret: this.config.clientSecret,
164
+ ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}),
165
+ })
166
+ const res = await this.fetchFn(this.endpoints.token, {
167
+ method: 'POST',
168
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
169
+ body,
170
+ })
171
+ if (!res.ok) {
172
+ const text = await res.text()
173
+ throw new OAuthExchangeError(
174
+ `GoogleSocialDriver.exchange: token endpoint returned ${res.status}.`,
175
+ { context: { status: res.status, body: text } },
176
+ )
177
+ }
178
+ return this.toOAuthTokens((await res.json()) as TokenResponse)
179
+ }
180
+
181
+ async profile(accessToken: string): Promise<SocialProfile> {
182
+ const res = await this.fetchFn(this.endpoints.userInfo, {
183
+ headers: { authorization: `Bearer ${accessToken}` },
184
+ })
185
+ if (res.status === 401) {
186
+ throw new InvalidTokenError('GoogleSocialDriver.profile: access token rejected.')
187
+ }
188
+ if (!res.ok) {
189
+ const text = await res.text()
190
+ throw new SocialProviderError(
191
+ `GoogleSocialDriver.profile: userinfo endpoint returned ${res.status}.`,
192
+ { provider: PROVIDER, operation: 'profile', context: { status: res.status, body: text } },
193
+ )
194
+ }
195
+ const u = (await res.json()) as UserInfoResponse
196
+ return {
197
+ id: u.sub,
198
+ provider: PROVIDER,
199
+ ...(u.email ? { email: u.email } : {}),
200
+ ...(u.email_verified !== undefined ? { emailVerified: u.email_verified } : {}),
201
+ ...(u.name ? { name: u.name } : {}),
202
+ ...(u.picture ? { avatarUrl: u.picture } : {}),
203
+ ...(u.locale ? { locale: u.locale } : {}),
204
+ metadata: {
205
+ ...(u.given_name ? { givenName: u.given_name } : {}),
206
+ ...(u.family_name ? { familyName: u.family_name } : {}),
207
+ },
208
+ raw: u,
209
+ }
210
+ }
211
+
212
+ async refresh(input: RefreshInput): Promise<OAuthTokens> {
213
+ const body = new URLSearchParams({
214
+ grant_type: 'refresh_token',
215
+ refresh_token: input.refreshToken,
216
+ client_id: this.config.clientId,
217
+ client_secret: this.config.clientSecret,
218
+ })
219
+ const res = await this.fetchFn(this.endpoints.token, {
220
+ method: 'POST',
221
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
222
+ body,
223
+ })
224
+ if (res.status === 400 || res.status === 401) {
225
+ const text = await res.text()
226
+ throw new InvalidTokenError(
227
+ `GoogleSocialDriver.refresh: refresh token rejected.`,
228
+ { context: { status: res.status, body: text } },
229
+ )
230
+ }
231
+ if (!res.ok) {
232
+ const text = await res.text()
233
+ throw new SocialProviderError(
234
+ `GoogleSocialDriver.refresh: token endpoint returned ${res.status}.`,
235
+ { provider: PROVIDER, operation: 'refresh', context: { status: res.status, body: text } },
236
+ )
237
+ }
238
+ // Google does NOT rotate refresh tokens; preserve the caller's
239
+ // current refresh token if the response omits it.
240
+ const json = (await res.json()) as TokenResponse
241
+ const tokens = this.toOAuthTokens(json)
242
+ if (!tokens.refreshToken) tokens.refreshToken = input.refreshToken
243
+ return tokens
244
+ }
245
+
246
+ async revoke(token: string): Promise<void> {
247
+ // Google's revoke endpoint takes the token as a query
248
+ // parameter OR a form body; we POST form for consistency
249
+ // with the rest of the driver.
250
+ const body = new URLSearchParams({ token })
251
+ const res = await this.fetchFn(this.endpoints.revoke, {
252
+ method: 'POST',
253
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
254
+ body,
255
+ })
256
+ if (!res.ok) {
257
+ const text = await res.text()
258
+ throw new SocialProviderError(
259
+ `GoogleSocialDriver.revoke: revoke endpoint returned ${res.status}.`,
260
+ { provider: PROVIDER, operation: 'revoke', context: { status: res.status, body: text } },
261
+ )
262
+ }
263
+ }
264
+
265
+ // ─── Internals ────────────────────────────────────────────────────────
266
+
267
+ private toOAuthTokens(t: TokenResponse): OAuthTokens {
268
+ const expiresAt =
269
+ typeof t.expires_in === 'number'
270
+ ? new Date(Date.now() + t.expires_in * 1000)
271
+ : undefined
272
+ return {
273
+ accessToken: t.access_token,
274
+ ...(t.refresh_token ? { refreshToken: t.refresh_token } : {}),
275
+ ...(t.id_token ? { idToken: t.id_token } : {}),
276
+ ...(expiresAt ? { expiresAt } : {}),
277
+ ...(t.scope ? { scope: t.scope } : {}),
278
+ tokenType: t.token_type ?? 'Bearer',
279
+ raw: t,
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Extract the `email` claim from a Google id_token (JWT). Same
286
+ * semantics as `emailFromLineIdToken` — decode-only, no JWS
287
+ * signature verification. Apps with stricter posture verify via
288
+ * Google's `tokeninfo?id_token=...` endpoint.
289
+ *
290
+ * Google's userinfo endpoint also returns `email` + `email_verified`,
291
+ * so most apps don't need this helper — it's here for paths that
292
+ * skip userinfo (e.g. server-only signin where the access token
293
+ * is discarded immediately after exchange).
294
+ */
295
+ export function emailFromGoogleIdToken(idToken: string): string | null {
296
+ const segments = idToken.split('.')
297
+ if (segments.length !== 3) {
298
+ throw new InvalidTokenError(
299
+ 'emailFromGoogleIdToken: id_token does not have 3 segments.',
300
+ )
301
+ }
302
+ const payload = decodeJwtSegment(segments[1]!)
303
+ return typeof payload.email === 'string' ? payload.email : null
304
+ }
305
+
306
+ function decodeJwtSegment(segment: string): JwtPayload {
307
+ const pad = segment.length % 4
308
+ const padded = pad === 0 ? segment : `${segment}${'='.repeat(4 - pad)}`
309
+ const b64 = padded.replace(/-/g, '+').replace(/_/g, '/')
310
+ try {
311
+ return JSON.parse(atob(b64)) as JwtPayload
312
+ } catch (cause) {
313
+ throw new InvalidTokenError('decodeJwtSegment: failed to parse JWT segment.', {
314
+ cause,
315
+ })
316
+ }
317
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `GoogleSocialProvider` — `ServiceProvider` that registers the
3
+ * Google driver factory on the `SocialManager`.
4
+ *
5
+ * Listed AFTER `SocialProvider` in `bootstrap/providers.ts`. The
6
+ * factory is invoked lazily on first `social.use(name)`;
7
+ * misconfigured credentials surface on first use.
8
+ */
9
+
10
+ import { type Application, ServiceProvider } from '@strav/kernel'
11
+ import { SocialConfigError } from '../social_error.ts'
12
+ import { SocialManager } from '../social_manager.ts'
13
+ import type { GoogleProviderConfig } from './google_config.ts'
14
+ import { GoogleSocialDriver } from './google_driver.ts'
15
+
16
+ export class GoogleSocialProvider extends ServiceProvider {
17
+ override readonly name = 'social-google'
18
+ override readonly dependencies = ['social']
19
+
20
+ override register(app: Application): void {
21
+ const manager = app.resolve(SocialManager)
22
+ manager.extend('google', ({ instanceName, config }) => {
23
+ const cfg = config as GoogleProviderConfig
24
+ if (!cfg.clientId || !cfg.clientSecret) {
25
+ throw new SocialConfigError(
26
+ `GoogleSocialProvider: \`clientId\` and \`clientSecret\` are required for provider "${instanceName}".`,
27
+ { context: { instanceName } },
28
+ )
29
+ }
30
+ return new GoogleSocialDriver({ instanceName, config: cfg })
31
+ })
32
+ }
33
+ }
@@ -0,0 +1,12 @@
1
+ // Public API of `@strav/social/google`.
2
+
3
+ export {
4
+ GOOGLE_ENDPOINTS,
5
+ type GoogleProviderConfig,
6
+ } from './google_config.ts'
7
+ export {
8
+ emailFromGoogleIdToken,
9
+ GoogleSocialDriver,
10
+ type GoogleDriverOptions,
11
+ } from './google_driver.ts'
12
+ export { GoogleSocialProvider } from './google_provider.ts'
package/src/index.ts CHANGED
@@ -1,15 +1,62 @@
1
- export { default, default as SocialManager } from './social_manager.ts'
1
+ // Public API of `@strav/social`.
2
+ //
3
+ // V1: provider-agnostic OAuth/OIDC client — normalized profile +
4
+ // token DTOs + multi-provider routing + state + PKCE helpers.
5
+ // Composes with `@strav/kernel` for the container and
6
+ // `@strav/http` (no direct import; for the eventual route helpers).
7
+ //
8
+ // Drivers ship as subpath imports:
9
+ // `@strav/social/line`, `@strav/social/google`,
10
+ // `@strav/social/facebook`. The `MockDriver` in `./drivers`
11
+ // is for tests + as the reference contract.
12
+ //
13
+ // Account-linking schema lives in `./ledger/`:
14
+ // - `social_account` tenanted table (tokens encrypted via
15
+ // kernel's cipher)
16
+ // - `SocialAccountRepository` with connect / disconnect /
17
+ // find{ByUser,ByProviderIdentity} helpers
18
+ // - `applySocialAccountMigration` for the table + composite
19
+ // unique + user_id index
2
20
 
3
- // Provider
4
- export { default as SocialProvider } from './social_provider.ts'
5
- export { default as SocialAccount } from './social_account.ts'
6
- export { social } from './helpers.ts'
7
- export { AbstractProvider, SocialError } from './abstract_provider.ts'
8
- export { GoogleProvider } from './providers/google_provider.ts'
9
- export { GitHubProvider } from './providers/github_provider.ts'
10
- export { DiscordProvider } from './providers/discord_provider.ts'
11
- export { FacebookProvider } from './providers/facebook_provider.ts'
12
- export { LinkedInProvider } from './providers/linkedin_provider.ts'
13
- export { LineProvider } from './providers/line_provider.ts'
14
- export type { SocialUser, SocialConfig, ProviderConfig, TokenResponse } from './types.ts'
15
- export type { SocialAccountData } from './social_account.ts'
21
+ export type * from './dto/index.ts'
22
+ export { MockDriver, type MockDriverOptions, unsupported } from './drivers/index.ts'
23
+ export {
24
+ applySocialAccountMigration,
25
+ type ApplySocialAccountMigrationOptions,
26
+ type ConnectInput,
27
+ type DisconnectInput,
28
+ SocialAccount,
29
+ SocialAccountAlreadyLinkedError,
30
+ SocialAccountRepository,
31
+ socialAccountSchema,
32
+ } from './ledger/index.ts'
33
+ export {
34
+ codeChallengeFor,
35
+ randomCodeVerifier,
36
+ randomState,
37
+ } from './pkce.ts'
38
+ export type { SocialCapability } from './social_capabilities.ts'
39
+ export type {
40
+ AuthorizeInput,
41
+ AuthorizeResult,
42
+ ExchangeInput,
43
+ RefreshInput,
44
+ SocialDriver,
45
+ SocialDriverFactory,
46
+ } from './social_driver.ts'
47
+ export {
48
+ InvalidTokenError,
49
+ OAuthExchangeError,
50
+ ProviderUnsupportedError,
51
+ SocialConfigError,
52
+ SocialError,
53
+ SocialProviderError,
54
+ StateMismatchError,
55
+ UnknownProviderError,
56
+ } from './social_error.ts'
57
+ export {
58
+ SocialManager,
59
+ type SocialManagerOptions,
60
+ } from './social_manager.ts'
61
+ export { SocialProvider } from './social_provider.ts'
62
+ export type { ProviderConfig, SocialConfig } from './types.ts'
@@ -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
+ }