@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,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,149 @@
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
+ // biome-ignore lint/style/useImportType: PostgresDatabase value import for @inject() metadata.
15
+ import { PostgresDatabase, quoteIdent, Repository, SchemaRegistry } from '@strav/database'
16
+ // biome-ignore lint/style/useImportType: Cipher + EventBus value imports for @inject() metadata.
17
+ import { Cipher, EventBus, inject, ulid } from '@strav/kernel'
18
+ import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
19
+ import { SocialAccountAlreadyLinkedError } from '../ledger/social_account_repository.ts'
20
+ import { TenantedSocialAccount } from './tenanted_social_account.ts'
21
+ import { tenantedSocialAccountSchema } from './tenanted_social_account_schema.ts'
22
+
23
+ export interface ConnectInput {
24
+ userId: string
25
+ provider: string
26
+ profile: SocialProfile
27
+ tokens: OAuthTokens
28
+ }
29
+
30
+ export interface DisconnectInput {
31
+ userId: string
32
+ provider: string
33
+ }
34
+
35
+ @inject()
36
+ export class TenantedSocialAccountRepository extends Repository<TenantedSocialAccount> {
37
+ static override readonly schema = tenantedSocialAccountSchema
38
+ static override readonly model = TenantedSocialAccount
39
+
40
+ // biome-ignore lint/complexity/noUselessConstructor: explicit constructor forces TS to emit `design:paramtypes` for @inject(). The fourth param is the Cipher for @encrypt token columns.
41
+ constructor(
42
+ db: PostgresDatabase,
43
+ events: EventBus,
44
+ registry?: SchemaRegistry,
45
+ cipher?: Cipher,
46
+ ) {
47
+ super(db, events, registry, cipher)
48
+ }
49
+
50
+ async connect(input: ConnectInput): Promise<TenantedSocialAccount> {
51
+ const existing = await this.findByProviderIdentity(
52
+ input.provider,
53
+ input.profile.id,
54
+ )
55
+ const now = new Date()
56
+
57
+ if (existing) {
58
+ if (existing.user_id !== input.userId) {
59
+ throw new SocialAccountAlreadyLinkedError({
60
+ provider: input.provider,
61
+ providerUserId: input.profile.id,
62
+ existingUserId: existing.user_id,
63
+ attemptedUserId: input.userId,
64
+ })
65
+ }
66
+ return this.update(existing, {
67
+ email: input.profile.email ?? null,
68
+ name: input.profile.name ?? null,
69
+ avatar_url: input.profile.avatarUrl ?? null,
70
+ locale: input.profile.locale ?? null,
71
+ access_token: input.tokens.accessToken,
72
+ refresh_token: input.tokens.refreshToken ?? null,
73
+ id_token: input.tokens.idToken ?? null,
74
+ expires_at: input.tokens.expiresAt ?? null,
75
+ scope: input.tokens.scope ?? null,
76
+ updated_at: now,
77
+ } as Partial<TenantedSocialAccount>)
78
+ }
79
+
80
+ return this.create({
81
+ id: ulid(),
82
+ user_id: input.userId,
83
+ provider: input.provider,
84
+ provider_user_id: input.profile.id,
85
+ email: input.profile.email ?? null,
86
+ name: input.profile.name ?? null,
87
+ avatar_url: input.profile.avatarUrl ?? null,
88
+ locale: input.profile.locale ?? null,
89
+ access_token: input.tokens.accessToken,
90
+ refresh_token: input.tokens.refreshToken ?? null,
91
+ id_token: input.tokens.idToken ?? null,
92
+ expires_at: input.tokens.expiresAt ?? null,
93
+ scope: input.tokens.scope ?? null,
94
+ metadata: {},
95
+ created_at: now,
96
+ updated_at: now,
97
+ } as Partial<TenantedSocialAccount>)
98
+ }
99
+
100
+ async disconnect(input: DisconnectInput): Promise<void> {
101
+ const table = quoteIdent(tenantedSocialAccountSchema.name)
102
+ await this.db.execute(
103
+ `DELETE FROM ${table} WHERE "user_id" = $1 AND "provider" = $2`,
104
+ [input.userId, input.provider],
105
+ )
106
+ }
107
+
108
+ async findByUser(userId: string): Promise<TenantedSocialAccount[]> {
109
+ const table = quoteIdent(tenantedSocialAccountSchema.name)
110
+ const rows = await this.db.query<Record<string, unknown>>(
111
+ `SELECT * FROM ${table} WHERE "user_id" = $1 ORDER BY "created_at"`,
112
+ [userId],
113
+ )
114
+ return Promise.all(rows.map((r) => this.hydrate(r)))
115
+ }
116
+
117
+ async findByUserAndProvider(
118
+ userId: string,
119
+ provider: string,
120
+ ): Promise<TenantedSocialAccount | null> {
121
+ const table = quoteIdent(tenantedSocialAccountSchema.name)
122
+ const rows = await this.db.query<Record<string, unknown>>(
123
+ `SELECT * FROM ${table} WHERE "user_id" = $1 AND "provider" = $2 LIMIT 1`,
124
+ [userId, provider],
125
+ )
126
+ if (rows.length === 0) return null
127
+ return this.hydrate(rows[0]!)
128
+ }
129
+
130
+ /**
131
+ * Sign-in lookup. Scoped by RLS to the current tenant — the
132
+ * same provider identity can exist in two tenants without
133
+ * collision; this query only sees the one in scope.
134
+ */
135
+ async findByProviderIdentity(
136
+ provider: string,
137
+ providerUserId: string,
138
+ ): Promise<TenantedSocialAccount | null> {
139
+ const table = quoteIdent(tenantedSocialAccountSchema.name)
140
+ const rows = await this.db.query<Record<string, unknown>>(
141
+ `SELECT * FROM ${table}
142
+ WHERE "provider" = $1 AND "provider_user_id" = $2
143
+ LIMIT 1`,
144
+ [provider, providerUserId],
145
+ )
146
+ if (rows.length === 0) return null
147
+ return this.hydrate(rows[0]!)
148
+ }
149
+ }
@@ -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
+ )
package/src/types.ts CHANGED
@@ -1,49 +1,21 @@
1
- export interface SocialUser {
2
- id: string
3
- name: string | null
4
- email: string | null
5
- /**
6
- * Whether the provider asserts the email has been verified by the user.
7
- *
8
- * Callers MUST check this before using `email` to match an existing
9
- * application user — linking by an unverified email is a known account-
10
- * takeover vector. See packages/social/CLAUDE.md ("Verified-email gate").
11
- */
12
- emailVerified: boolean
13
- avatar: string | null
14
- nickname: string | null
15
- token: string
16
- refreshToken: string | null
17
- expiresIn: number | null
18
- approvedScopes: string[]
19
- raw: Record<string, unknown>
20
- }
21
-
22
- export interface ProviderConfig {
23
- driver?: string
24
- clientId: string
25
- clientSecret: string
26
- redirectUrl: string
27
- scopes?: string[]
28
- /**
29
- * How to authenticate with the provider's token endpoint. Default
30
- * `'basic'` (HTTP Basic auth — RFC 6749 §2.3.1, MUST-support, keeps
31
- * `client_secret` out of body-logging surfaces). `'post'` falls back
32
- * to `client_secret` in the request body for providers that don't
33
- * accept Basic (e.g. Facebook). The `social` package picks the right
34
- * default per provider but you can override here if needed.
35
- */
36
- tokenEndpointAuthMethod?: 'basic' | 'post'
37
- }
1
+ /**
2
+ * `@strav/social` — runtime config shape.
3
+ *
4
+ * Multi-provider by default — apps register one entry per
5
+ * provider they want available (e.g. `line`, `google`,
6
+ * `facebook`). The driver field discriminates the adapter; the
7
+ * rest is driver-specific (`clientId`, `clientSecret`, …).
8
+ */
38
9
 
39
10
  export interface SocialConfig {
40
- userKey: string
11
+ /** Default routing target for unqualified `social.*` calls. Keyed into `providers`. */
12
+ default: string
41
13
  providers: Record<string, ProviderConfig>
42
14
  }
43
15
 
44
- export interface TokenResponse {
45
- accessToken: string
46
- refreshToken: string | null
47
- expiresIn: number | null
48
- scope: string | null
16
+ export interface ProviderConfig {
17
+ /** Driver identifier — must match a built-in (`'line'` / `'google'` / `'facebook'`) or a name registered via `manager.extend(name, factory)`. */
18
+ driver: string
19
+ /** Driver-specific fields — see each adapter's `*Config`. */
20
+ [key: string]: unknown
49
21
  }
package/CHANGELOG.md DELETED
@@ -1,19 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.1
4
-
5
- ### Added
6
-
7
- - Facebook provider (Graph API v21.0)
8
- - LinkedIn provider (OpenID Connect userinfo endpoint)
9
-
10
- ## 0.1.0
11
-
12
- ### Added
13
-
14
- - Initial release with OAuth 2.0 social authentication
15
- - Built-in providers: Google, GitHub, Discord
16
- - `SocialManager` with driver-based architecture and custom provider extensibility
17
- - Session-based CSRF state verification with `stateless()` opt-out
18
- - `social` helper for fluent API access
19
- - `AbstractProvider` base class for building custom providers
package/README.md DELETED
@@ -1,78 +0,0 @@
1
- # @strav/social
2
-
3
- OAuth social authentication for the [Strav](https://www.npmjs.com/package/@strav/core) framework. Sign in with Google, GitHub, Discord, Facebook, and LinkedIn using a fluent, driver-based API.
4
-
5
- ## Install
6
-
7
- ```bash
8
- bun add @strav/social
9
- bun strav install social
10
- ```
11
-
12
- Requires `@strav/core` as a peer dependency.
13
-
14
- ## Setup
15
-
16
- ```ts
17
- import { SocialProvider } from '@strav/social'
18
-
19
- app.use(new SocialProvider())
20
- ```
21
-
22
- ## Usage
23
-
24
- ```ts
25
- import { social } from '@strav/social'
26
-
27
- // Redirect to provider
28
- r.get('/auth/github', ctx => {
29
- return social.driver('github').redirect(ctx)
30
- })
31
-
32
- // Handle callback
33
- r.get('/auth/github/callback', async ctx => {
34
- const githubUser = await social.driver('github').user(ctx)
35
-
36
- // githubUser.id, .name, .email, .avatar, .nickname, .token
37
- let user = await User.findBy('email', githubUser.email)
38
- if (!user) {
39
- user = await User.create({ name: githubUser.name, email: githubUser.email })
40
- }
41
-
42
- await social.findOrCreate('github', githubUser, user)
43
- // authenticate session...
44
- })
45
- ```
46
-
47
- ## Providers
48
-
49
- - **Google** — OpenID Connect with Workspace domain restriction
50
- - **GitHub** — User profile with verified email fallback
51
- - **Discord** — Profile with computed avatar URLs
52
- - **Facebook** — Graph API v21.0
53
- - **LinkedIn** — OpenID Connect userinfo
54
-
55
- ## Fluent API
56
-
57
- ```ts
58
- social.driver('github').scopes(['repo', 'gist']).redirect(ctx)
59
- social.driver('google').with({ hd: 'example.com' }).redirect(ctx)
60
- const user = await social.driver('google').userFromToken(accessToken)
61
- ```
62
-
63
- ## Custom Providers
64
-
65
- ```ts
66
- import { AbstractProvider, social } from '@strav/social'
67
-
68
- class SpotifyProvider extends AbstractProvider { /* ... */ }
69
- social.extend('spotify', config => new SpotifyProvider(config))
70
- ```
71
-
72
- ## Documentation
73
-
74
- See the full [Social guide](../../guides/social.md).
75
-
76
- ## License
77
-
78
- MIT
@@ -1,182 +0,0 @@
1
- import type { Context, Session } from '@strav/http'
2
- import { randomHex, ExternalServiceError, scrubProviderError } from '@strav/kernel'
3
- import type { ProviderConfig, SocialUser, TokenResponse } from './types.ts'
4
-
5
- const STATE_KEY = 'social_state'
6
-
7
- export abstract class AbstractProvider {
8
- abstract readonly name: string
9
-
10
- protected config: ProviderConfig
11
- protected _scopes: string[]
12
- protected _parameters: Record<string, string> = {}
13
-
14
- constructor(config: ProviderConfig) {
15
- this.config = config
16
- this._scopes = config.scopes ?? this.getDefaultScopes()
17
- }
18
-
19
- // ---------------------------------------------------------------------------
20
- // Template methods — each provider implements these
21
- // ---------------------------------------------------------------------------
22
-
23
- protected abstract getDefaultScopes(): string[]
24
- protected abstract getAuthUrl(): string
25
- protected abstract getTokenUrl(): string
26
- protected abstract getUserByToken(token: string): Promise<Record<string, unknown>>
27
- protected abstract mapUserToObject(data: Record<string, unknown>): SocialUser
28
-
29
- // ---------------------------------------------------------------------------
30
- // Fluent API
31
- // ---------------------------------------------------------------------------
32
-
33
- scopes(scopes: string[]): this {
34
- this._scopes = [...new Set([...this._scopes, ...scopes])]
35
- return this
36
- }
37
-
38
- setScopes(scopes: string[]): this {
39
- this._scopes = scopes
40
- return this
41
- }
42
-
43
- with(params: Record<string, string>): this {
44
- this._parameters = { ...this._parameters, ...params }
45
- return this
46
- }
47
-
48
- // ---------------------------------------------------------------------------
49
- // OAuth flow
50
- // ---------------------------------------------------------------------------
51
-
52
- redirect(ctx: Context): Response {
53
- const state = randomHex(32)
54
- const session = ctx.get<Session>('session')
55
- session.set(STATE_KEY, state)
56
-
57
- const url = this.buildAuthUrl(state)
58
- return ctx.redirect(url)
59
- }
60
-
61
- async user(ctx: Context): Promise<SocialUser> {
62
- const session = ctx.get<Session>('session')
63
- const expectedState = session.get<string>(STATE_KEY)
64
- const returnedState = ctx.query.get('state')
65
-
66
- if (!expectedState || expectedState !== returnedState) {
67
- throw new SocialError('Invalid state parameter. Possible CSRF attack.')
68
- }
69
-
70
- session.forget(STATE_KEY)
71
-
72
- const code = ctx.query.get('code')
73
- if (!code) {
74
- const error = ctx.query.get('error')
75
- throw new SocialError(error ? `OAuth error: ${error}` : 'Missing authorization code.')
76
- }
77
-
78
- const token = await this.getAccessToken(code)
79
- const data = await this.getUserByToken(token.accessToken)
80
- const user = this.mapUserToObject(data)
81
-
82
- user.token = token.accessToken
83
- user.refreshToken = token.refreshToken
84
- user.expiresIn = token.expiresIn
85
- user.approvedScopes = token.scope ? token.scope.split(/[\s,]+/) : this._scopes
86
- user.raw = data
87
-
88
- return user
89
- }
90
-
91
- async userFromToken(token: string): Promise<SocialUser> {
92
- const data = await this.getUserByToken(token)
93
- const user = this.mapUserToObject(data)
94
-
95
- user.token = token
96
- user.refreshToken = null
97
- user.expiresIn = null
98
- user.approvedScopes = this._scopes
99
- user.raw = data
100
-
101
- return user
102
- }
103
-
104
- // ---------------------------------------------------------------------------
105
- // Internal
106
- // ---------------------------------------------------------------------------
107
-
108
- /**
109
- * Per-provider override of the default token-endpoint auth method.
110
- * Override this in subclasses for providers that require a non-`basic`
111
- * default (e.g. Facebook prefers `post`).
112
- */
113
- protected defaultTokenEndpointAuthMethod(): 'basic' | 'post' {
114
- return 'basic'
115
- }
116
-
117
- protected async getAccessToken(code: string): Promise<TokenResponse> {
118
- const method = this.config.tokenEndpointAuthMethod ?? this.defaultTokenEndpointAuthMethod()
119
-
120
- const headers: Record<string, string> = {
121
- 'Content-Type': 'application/x-www-form-urlencoded',
122
- Accept: 'application/json',
123
- }
124
-
125
- const body: Record<string, string> = {
126
- grant_type: 'authorization_code',
127
- client_id: this.config.clientId,
128
- code,
129
- redirect_uri: this.config.redirectUrl,
130
- }
131
-
132
- if (method === 'basic') {
133
- // RFC 6749 §2.3.1 — MUST-support form. Keeps client_secret out of
134
- // body-logging surfaces.
135
- const credentials = `${this.config.clientId}:${this.config.clientSecret}`
136
- headers.Authorization = `Basic ${Buffer.from(credentials, 'utf8').toString('base64')}`
137
- } else {
138
- // Fallback for providers that only accept secret-in-body.
139
- body.client_secret = this.config.clientSecret
140
- }
141
-
142
- const response = await fetch(this.getTokenUrl(), {
143
- method: 'POST',
144
- headers,
145
- body: new URLSearchParams(body),
146
- })
147
-
148
- if (!response.ok) {
149
- const text = await response.text()
150
- throw new ExternalServiceError(this.name, response.status, scrubProviderError(text))
151
- }
152
-
153
- const data = (await response.json()) as Record<string, unknown>
154
-
155
- return {
156
- accessToken: data.access_token as string,
157
- refreshToken: (data.refresh_token as string) ?? null,
158
- expiresIn: (data.expires_in as number) ?? null,
159
- scope: (data.scope as string) ?? null,
160
- }
161
- }
162
-
163
- protected buildAuthUrl(state: string): string {
164
- const params = new URLSearchParams({
165
- client_id: this.config.clientId,
166
- redirect_uri: this.config.redirectUrl,
167
- response_type: 'code',
168
- scope: this._scopes.join(' '),
169
- state,
170
- ...this._parameters,
171
- })
172
-
173
- return `${this.getAuthUrl()}?${params.toString()}`
174
- }
175
- }
176
-
177
- export class SocialError extends Error {
178
- constructor(message: string) {
179
- super(message)
180
- this.name = 'SocialError'
181
- }
182
- }
package/src/helpers.ts DELETED
@@ -1,31 +0,0 @@
1
- import type { AbstractProvider } from './abstract_provider.ts'
2
- import SocialAccount from './social_account.ts'
3
- import SocialManager from './social_manager.ts'
4
- import type { ProviderConfig, SocialUser } from './types.ts'
5
- import type { SocialAccountData } from './social_account.ts'
6
-
7
- export const social = {
8
- driver(name: string): AbstractProvider {
9
- return SocialManager.driver(name)
10
- },
11
-
12
- extend(name: string, factory: (config: ProviderConfig) => AbstractProvider): void {
13
- SocialManager.extend(name, factory)
14
- },
15
-
16
- /**
17
- * Find an existing social account by provider or create a new one.
18
- * If the account already exists, its tokens are updated.
19
- *
20
- * @example
21
- * const socialUser = await social.driver('google').user(ctx)
22
- * const { account, created } = await social.findOrCreate('google', socialUser, user)
23
- */
24
- findOrCreate(
25
- provider: string,
26
- socialUser: SocialUser,
27
- user: unknown
28
- ): Promise<{ account: SocialAccountData; created: boolean }> {
29
- return SocialAccount.findOrCreate(provider, socialUser, user)
30
- },
31
- }
@@ -1,59 +0,0 @@
1
- import { ExternalServiceError, scrubProviderError } from '@strav/kernel'
2
- import { AbstractProvider } from '../abstract_provider.ts'
3
- import type { SocialUser } from '../types.ts'
4
-
5
- export class DiscordProvider extends AbstractProvider {
6
- readonly name = 'Discord'
7
-
8
- protected getDefaultScopes(): string[] {
9
- return ['identify', 'email']
10
- }
11
-
12
- protected getAuthUrl(): string {
13
- return 'https://discord.com/api/oauth2/authorize'
14
- }
15
-
16
- protected getTokenUrl(): string {
17
- return 'https://discord.com/api/oauth2/token'
18
- }
19
-
20
- protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
21
- const response = await fetch('https://discord.com/api/v10/users/@me', {
22
- headers: { Authorization: `Bearer ${token}` },
23
- })
24
-
25
- if (!response.ok) {
26
- throw new ExternalServiceError(
27
- 'Discord',
28
- response.status,
29
- scrubProviderError(await response.text())
30
- )
31
- }
32
-
33
- return (await response.json()) as Record<string, unknown>
34
- }
35
-
36
- protected mapUserToObject(data: Record<string, unknown>): SocialUser {
37
- let avatar: string | null = null
38
- if (data.avatar) {
39
- avatar = `https://cdn.discordapp.com/avatars/${data.id}/${data.avatar}.png`
40
- } else if (data.id) {
41
- const index = Number((BigInt(data.id as string) >> 22n) % 6n)
42
- avatar = `https://cdn.discordapp.com/embed/avatars/${index}.png`
43
- }
44
-
45
- return {
46
- id: data.id as string,
47
- name: (data.global_name as string) ?? null,
48
- email: (data.email as string) ?? null,
49
- emailVerified: data.verified === true,
50
- avatar,
51
- nickname: (data.username as string) ?? null,
52
- token: '',
53
- refreshToken: null,
54
- expiresIn: null,
55
- approvedScopes: [],
56
- raw: data,
57
- }
58
- }
59
- }