@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.
- package/package.json +23 -16
- package/src/drivers/facebook/facebook_config.ts +68 -0
- package/src/drivers/facebook/facebook_driver.ts +321 -0
- package/src/drivers/facebook/facebook_provider.ts +29 -0
- package/src/drivers/facebook/index.ts +12 -0
- package/src/drivers/google/google_config.ts +44 -0
- package/src/drivers/google/google_driver.ts +317 -0
- package/src/drivers/google/google_provider.ts +33 -0
- package/src/drivers/google/index.ts +12 -0
- package/src/drivers/index.ts +2 -0
- package/src/drivers/line/index.ts +12 -0
- package/src/drivers/line/line_config.ts +47 -0
- package/src/drivers/line/line_driver.ts +310 -0
- package/src/drivers/line/line_provider.ts +34 -0
- package/src/drivers/mock_driver.ts +170 -0
- package/src/drivers/unsupported.ts +17 -0
- package/src/dto/index.ts +4 -0
- package/src/dto/oauth_tokens.ts +26 -0
- package/src/dto/social_profile.ts +37 -0
- package/src/index.ts +61 -14
- package/src/ledger/apply_social_account_migration.ts +66 -0
- package/src/ledger/index.ts +12 -0
- package/src/ledger/social_account.ts +32 -0
- package/src/ledger/social_account_repository.ts +203 -0
- package/src/ledger/social_account_schema.ts +75 -0
- package/src/pkce.ts +63 -0
- package/src/social_capabilities.ts +35 -0
- package/src/social_driver.ts +143 -0
- package/src/social_error.ts +155 -0
- package/src/social_manager.ts +92 -74
- package/src/social_provider.ts +41 -7
- package/src/tenanted/apply_tenanted_social_account_migration.ts +45 -0
- package/src/tenanted/index.ts +18 -0
- package/src/tenanted/tenanted_social_account.ts +30 -0
- package/src/tenanted/tenanted_social_account_repository.ts +136 -0
- package/src/tenanted/tenanted_social_account_schema.ts +44 -0
- package/src/types.ts +15 -43
- package/CHANGELOG.md +0 -19
- package/README.md +0 -78
- package/src/abstract_provider.ts +0 -182
- package/src/helpers.ts +0 -31
- package/src/providers/discord_provider.ts +0 -59
- package/src/providers/facebook_provider.ts +0 -69
- package/src/providers/github_provider.ts +0 -75
- package/src/providers/google_provider.ts +0 -51
- package/src/providers/line_provider.ts +0 -73
- package/src/providers/linkedin_provider.ts +0 -51
- package/src/schema.ts +0 -13
- package/src/social_account.ts +0 -238
- package/stubs/config/social.ts +0 -22
- package/stubs/schemas/social_account.ts +0 -13
- 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
|
+
}
|
package/src/social_manager.ts
CHANGED
|
@@ -1,98 +1,116 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
36
|
+
export interface SocialManagerOptions {
|
|
37
|
+
config: SocialConfig
|
|
38
|
+
}
|
|
21
39
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
97
|
+
authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
|
|
98
|
+
return this.use().authorize(input)
|
|
99
|
+
}
|
|
65
100
|
|
|
66
|
-
|
|
67
|
-
|
|
101
|
+
exchange(input: ExchangeInput): Promise<OAuthTokens> {
|
|
102
|
+
return this.use().exchange(input)
|
|
103
|
+
}
|
|
68
104
|
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
SocialManager._extensions.set(name, factory)
|
|
109
|
+
refresh(input: RefreshInput): Promise<OAuthTokens> {
|
|
110
|
+
return this.use().refresh(input)
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
SocialManager._extensions.clear()
|
|
113
|
+
revoke(token: string): Promise<void> {
|
|
114
|
+
return this.use().revoke(token)
|
|
97
115
|
}
|
|
98
116
|
}
|
package/src/social_provider.ts
CHANGED
|
@@ -1,16 +1,50 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
)
|