@pattern-stack/codegen 0.6.6 → 0.6.8

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.
@@ -0,0 +1,125 @@
1
+ # auth-integrations starter
2
+
3
+ Canonical OAuth2 integration storage for apps using the
4
+ `@pattern-stack/codegen` auth subsystem (ADR-031 / `runtime/subsystems/auth/`).
5
+
6
+ The auth subsystem ships the abstract OAuth2 plumbing — `OAuth2RefreshStrategy`,
7
+ `AuthController`, `withAuthRetry`, encryption, error types, and a set of narrow
8
+ hexagonal ports for integration storage. It deliberately doesn't ship the
9
+ concrete `integrations` table or the adapters that satisfy those ports, because
10
+ every consumer would need to fork them. **This starter is what plugs that gap.**
11
+
12
+ ## What ships here
13
+
14
+ ```
15
+ examples/auth-integrations/
16
+ definitions/
17
+ entities/
18
+ integration.yaml # canonical entity (run cdp entity new)
19
+
20
+ runtime/integrations/ # vendored next to the codegen-emitted
21
+ # integration entity module — i.e.
22
+ # <backend_src>/modules/integrations/
23
+ # (override via paths.modules_dir).
24
+ adapters/
25
+ integration-reader.adapter.ts # IIntegrationReader impl
26
+ integration-token-writer.adapter.ts # IIntegrationTokenWriter impl
27
+ integration-grant-sink.adapter.ts # IIntegrationGrantSink impl
28
+ facade/
29
+ integrations.service.ts # consumer-facing facade
30
+ oauth/
31
+ use-cases/
32
+ create-or-update-from-oauth-grant.use-case.ts
33
+ mark-integration-requires-reauth.use-case.ts
34
+ disconnect-integration.use-case.ts
35
+ list-user-integrations.use-case.ts
36
+ integrations-auth.module.ts # @Global() — binds the three
37
+ # AUTH_INTEGRATION_* tokens.
38
+ ```
39
+
40
+ ## How to install
41
+
42
+ ```bash
43
+ cdp subsystem install auth # one-time: vendor the auth subsystem
44
+ cdp subsystem install auth-integrations
45
+ cdp entity new integration # emits the entity module next to the vendor
46
+ ```
47
+
48
+ The `auth-integrations` install:
49
+ - copies `definitions/entities/integration.yaml` into your configured
50
+ `paths.entities` (or legacy `paths.entities_dir`) directory.
51
+ - vendors `runtime/integrations/**` under
52
+ `<backend_src>/modules/integrations/` (override via `paths.modules_dir`),
53
+ rewriting bare `@pattern-stack/codegen/runtime/subsystems/auth` imports to
54
+ relative paths that resolve against the vendored auth subsystem at
55
+ `<paths.subsystems>/auth`.
56
+ - appends a TODO to `<backend_src>/app.module.ts` reminding you to register
57
+ `IntegrationsAuthModule` AFTER `AuthModule.forRoot(...)`.
58
+
59
+ `IntegrationsAuthModule` is `@Global()` because `AuthController` (inside
60
+ `AuthModule`'s injector) resolves the `AUTH_INTEGRATION_*` providers exposed
61
+ by it.
62
+
63
+ ## Two interfaces, two purposes
64
+
65
+ The auth subsystem requires three narrow ports:
66
+
67
+ | Token | Port | Used by |
68
+ | -------------------------------- | -------------------------- | ------------------------------ |
69
+ | `AUTH_INTEGRATION_READER` | `IIntegrationReader` | `OAuth2RefreshStrategy.resolve` |
70
+ | `AUTH_INTEGRATION_TOKEN_WRITER` | `IIntegrationTokenWriter` | `OAuth2RefreshStrategy.resolve` |
71
+ | `AUTH_INTEGRATION_GRANT_SINK` | `IIntegrationGrantSink` | `AuthController.callback` |
72
+
73
+ These stay narrow on purpose — a non-codegen consumer with a hand-rolled
74
+ integrations table can satisfy them without pulling in the rest of this
75
+ starter.
76
+
77
+ The starter's `IntegrationsService` is **a separate, richer interface** that
78
+ your app code (controllers, handlers, frontend-facing routes) talks to
79
+ directly. It composes the use cases, applies encryption, and exposes
80
+ consumer-shaped methods like `findByUserAndProvider`, `listByUser`,
81
+ `createOrUpdateFromOAuthGrant`, `markRequiresReauth`, and `disconnect`.
82
+
83
+ Same precedent as EAV: `FieldValueService.upsertFieldsTransactional` is
84
+ wider than `IFieldValueRepository.upsertCurrentValues`.
85
+
86
+ ## `scopes` is `json`, not `string_array`
87
+
88
+ This is deliberate. The codegen `string_array` field type currently emits
89
+ `z.unknown()` in generated DTOs (open bug #281). Storing as `json` keeps the
90
+ column shape correct (`jsonb` holding `string[]`) without DTO regressions.
91
+ Once #281 lands, the field can be re-typed to `string_array` without behavior
92
+ change.
93
+
94
+ **Do not "clean up" to `string_array` until #281 ships.**
95
+
96
+ ## `provider` is `string`, not `enum`
97
+
98
+ Adding a new provider (`google`, `gusto`, …) should be a code change (a new
99
+ `IProviderStrategy` registered in `STRATEGY_REGISTRY`), not a YAML/migration
100
+ change. The string column matches the strategy registry's key type and
101
+ supports any provider slug your app cares about.
102
+
103
+ ## Smoke checklist
104
+
105
+ After install:
106
+
107
+ - [ ] `IntegrationsService.findByUserAndProvider(userId, 'hubspot-crm')`
108
+ returns `null` for missing rows, decrypted-creds for present rows.
109
+ - [ ] `createOrUpdateFromOAuthGrant({ userId, provider, accessToken, ... })`
110
+ upserts on `(user_id, provider)`. Re-running with a different access
111
+ token replaces the prior token; omitting `refreshToken` keeps the
112
+ existing ciphertext.
113
+ - [ ] `markRequiresReauth(integrationId)` flips status to `requires_reauth`.
114
+ - [ ] `disconnect(integrationId)` flips status to `revoked` and clears the
115
+ stored ciphertexts.
116
+ - [ ] `OAuth2RefreshStrategy.resolve()` end-to-end refresh works against a
117
+ provider-strategy you've registered (out of scope — provider strategies
118
+ are consumer-side per ADR-031).
119
+
120
+ ## Related
121
+
122
+ - ADR-031 — auth subsystem (Accepted)
123
+ - #285 — this starter (tracking issue)
124
+ - #286 — `AuthController` + integration-store ports (merged in PR #289)
125
+ - #287 — `cdp subsystem install auth-integrations` template (follow-up)
@@ -0,0 +1,98 @@
1
+ # auth-integrations: Integration
2
+ # Canonical OAuth2 integration row — one per (user_id, provider) pair.
3
+ # Vendored alongside the auth subsystem to satisfy IIntegrationReader /
4
+ # IIntegrationTokenWriter / IIntegrationGrantSink ports out of the box.
5
+ #
6
+ # `provider` stays a string (not enum) intentionally so consumers can add
7
+ # provider keys ('hubspot-crm', 'salesforce-crm', 'google', …) without
8
+ # forking the entity YAML. Strategy lookup happens in code via
9
+ # STRATEGY_REGISTRY.
10
+
11
+ entity:
12
+ name: integration
13
+ plural: integrations
14
+ table: integrations
15
+ pattern: Base
16
+ folder_structure: nested
17
+
18
+ fields:
19
+ user_id:
20
+ type: uuid
21
+ required: true
22
+ index: true
23
+
24
+ # Provider slug — matches the strategy.provider in STRATEGY_REGISTRY.
25
+ # Stays string (not enum) so adding a new provider is a code change,
26
+ # not a YAML/migration change. See README.
27
+ provider:
28
+ type: string
29
+ required: true
30
+ index: true
31
+ max_length: 64
32
+
33
+ external_account_id:
34
+ type: string
35
+ nullable: true
36
+ max_length: 255
37
+
38
+ # Ciphertexts — encryption is applied inside the IntegrationsService
39
+ # facade + adapter layer (via IEncryptionKey). The DB row only ever
40
+ # holds the encrypted form; decryption happens on read in the reader
41
+ # adapter.
42
+ access_token_encrypted:
43
+ type: string
44
+ nullable: true
45
+
46
+ refresh_token_encrypted:
47
+ type: string
48
+ nullable: true
49
+
50
+ expires_at:
51
+ type: datetime
52
+ nullable: true
53
+
54
+ # NOTE: typed `json` rather than `string_array` deliberately. The
55
+ # `string_array` codegen field type currently emits `z.unknown()` in
56
+ # generated DTOs (open bug #281). Storing as `json` keeps the column
57
+ # shape correct (jsonb holding string[]) without DTO regressions.
58
+ # Once #281 lands, this can be re-typed to `string_array` without
59
+ # behavior change. Do NOT "clean up" until then.
60
+ scopes:
61
+ type: json
62
+ nullable: true
63
+
64
+ # SFDC-specific instance URL; null for providers that don't need it.
65
+ instance_url:
66
+ type: string
67
+ nullable: true
68
+ max_length: 512
69
+
70
+ provider_metadata:
71
+ type: json
72
+ nullable: true
73
+
74
+ status:
75
+ type: enum
76
+ choices: [active, requires_reauth, revoked]
77
+ required: true
78
+ default: active
79
+ index: true
80
+
81
+ behaviors:
82
+ - timestamps
83
+
84
+ queries:
85
+ # Primary lookup: "does this user already have a connection to this
86
+ # provider?" — used by createOrUpdateFromOAuthGrant + every consumer
87
+ # that loads tokens for an outbound API call.
88
+ - by: [user_id, provider]
89
+ unique: true
90
+
91
+ # Settings page: list a user's connected integrations, newest first.
92
+ - by: [user_id]
93
+ order: created_at desc
94
+
95
+ # Operational query: find broken integrations needing reauth across
96
+ # all users (admin / monitoring).
97
+ - by: [provider, status]
98
+ order: updated_at desc
@@ -0,0 +1,29 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type {
3
+ IIntegrationGrantSink,
4
+ IntegrationGrantInput,
5
+ } from '@pattern-stack/codegen/runtime/subsystems/auth';
6
+ import { CreateOrUpdateFromOAuthGrantUseCase } from '../oauth/use-cases/create-or-update-from-oauth-grant.use-case';
7
+
8
+ /**
9
+ * `IIntegrationGrantSink` adapter — pass-through to
10
+ * `CreateOrUpdateFromOAuthGrantUseCase`. The auth subsystem's
11
+ * `AuthController.callback` invokes this after
12
+ * `IProviderStrategy.exchangeCodeForTokens`.
13
+ *
14
+ * This adapter injects the use case directly (not the
15
+ * `IntegrationsService` facade) for symmetry with the reader and
16
+ * token-writer adapters, which also bypass the facade and talk to
17
+ * the codegen-emitted layer directly. The port and the use case share
18
+ * the exact same `IntegrationGrantInput` shape, so no field mapping is
19
+ * needed — encryption, upsert resolution, and status handling all live
20
+ * inside the use case.
21
+ */
22
+ @Injectable()
23
+ export class IntegrationGrantSinkAdapter implements IIntegrationGrantSink {
24
+ constructor(private readonly useCase: CreateOrUpdateFromOAuthGrantUseCase) {}
25
+
26
+ async createOrUpdateFromOAuthGrant(input: IntegrationGrantInput): Promise<void> {
27
+ await this.useCase.execute(input);
28
+ }
29
+ }
@@ -0,0 +1,52 @@
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+ import {
3
+ ENCRYPTION_KEY,
4
+ type DecryptedIntegration,
5
+ type IEncryptionKey,
6
+ type IIntegrationReader,
7
+ } from '@pattern-stack/codegen/runtime/subsystems/auth';
8
+ import { IntegrationService } from '../integration.service';
9
+
10
+ /**
11
+ * `IIntegrationReader` adapter — fetches the integration row by id and
12
+ * decrypts its ciphertexts to satisfy the auth subsystem's read port.
13
+ *
14
+ * Stays narrow on purpose: this adapter exists purely to feed
15
+ * `OAuth2RefreshStrategy.resolve()`. Anything wider belongs in
16
+ * `IntegrationsService` (the consumer-facing facade).
17
+ *
18
+ * Note: this duplicates the decryption logic in `IntegrationsService`
19
+ * by design — the adapter must not depend on the facade because the
20
+ * facade depends on the use cases which depend on the adapter (well,
21
+ * not directly, but via the same module). Keeping the read path
22
+ * standalone avoids a circular DI graph and matches the auth
23
+ * subsystem's "narrow port" contract.
24
+ */
25
+ @Injectable()
26
+ export class IntegrationReaderAdapter implements IIntegrationReader {
27
+ constructor(
28
+ private readonly integrations: IntegrationService,
29
+ @Inject(ENCRYPTION_KEY) private readonly encryption: IEncryptionKey,
30
+ ) {}
31
+
32
+ async findByIdDecrypted(integrationId: string): Promise<DecryptedIntegration | null> {
33
+ const row = await this.integrations.findById(integrationId);
34
+ if (!row) return null;
35
+
36
+ const accessToken = row.accessTokenEncrypted
37
+ ? await this.encryption.decrypt(row.accessTokenEncrypted)
38
+ : '';
39
+ const refreshToken = row.refreshTokenEncrypted
40
+ ? await this.encryption.decrypt(row.refreshTokenEncrypted)
41
+ : null;
42
+
43
+ return {
44
+ id: row.id,
45
+ provider: row.provider,
46
+ accessToken,
47
+ refreshToken,
48
+ expiresAt: row.expiresAt,
49
+ providerMetadata: (row.providerMetadata as Record<string, unknown> | null) ?? null,
50
+ };
51
+ }
52
+ }
@@ -0,0 +1,43 @@
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+ import {
3
+ ENCRYPTION_KEY,
4
+ type IEncryptionKey,
5
+ type IIntegrationTokenWriter,
6
+ type IntegrationTokenUpdate,
7
+ } from '@pattern-stack/codegen/runtime/subsystems/auth';
8
+ import { IntegrationService } from '../integration.service';
9
+
10
+ /**
11
+ * `IIntegrationTokenWriter` adapter — encrypts the new access token
12
+ * (and rotated refresh token, if present) and persists them onto the
13
+ * integration row.
14
+ *
15
+ * `IntegrationTokenUpdate.refreshToken` semantics:
16
+ * - `undefined` → provider didn't rotate, leave existing ciphertext
17
+ * - `string` → provider rotated, re-encrypt + persist
18
+ *
19
+ * On a successful refresh we also flip status back to 'active' — if
20
+ * the row was previously `requires_reauth` and the user re-connected,
21
+ * a successful refresh is the signal that the integration is healthy
22
+ * again.
23
+ */
24
+ @Injectable()
25
+ export class IntegrationTokenWriterAdapter implements IIntegrationTokenWriter {
26
+ constructor(
27
+ private readonly integrations: IntegrationService,
28
+ @Inject(ENCRYPTION_KEY) private readonly encryption: IEncryptionKey,
29
+ ) {}
30
+
31
+ async persistRefresh(update: IntegrationTokenUpdate): Promise<void> {
32
+ const accessTokenEncrypted = await this.encryption.encrypt(update.accessToken);
33
+ const patch: Record<string, unknown> = {
34
+ accessTokenEncrypted,
35
+ expiresAt: update.expiresAt,
36
+ status: 'active',
37
+ };
38
+ if (update.refreshToken !== undefined) {
39
+ patch.refreshTokenEncrypted = await this.encryption.encrypt(update.refreshToken);
40
+ }
41
+ await this.integrations.update(update.integrationId, patch);
42
+ }
43
+ }
@@ -0,0 +1,150 @@
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+ import {
3
+ ENCRYPTION_KEY,
4
+ type IEncryptionKey,
5
+ type IntegrationGrantInput,
6
+ } from '@pattern-stack/codegen/runtime/subsystems/auth';
7
+ import { IntegrationService } from '../integration.service';
8
+ import type { Integration } from '../integration.entity';
9
+ import { CreateOrUpdateFromOAuthGrantUseCase } from '../oauth/use-cases/create-or-update-from-oauth-grant.use-case';
10
+ import { DisconnectIntegrationUseCase } from '../oauth/use-cases/disconnect-integration.use-case';
11
+ import { ListUserIntegrationsUseCase } from '../oauth/use-cases/list-user-integrations.use-case';
12
+ import { MarkIntegrationRequiresReauthUseCase } from '../oauth/use-cases/mark-integration-requires-reauth.use-case';
13
+
14
+ /**
15
+ * Decrypted integration shape — used by consumer code that needs to
16
+ * make outbound API calls (frontend never sees this; it's server-side
17
+ * only). Mirrors the auth subsystem's `DecryptedIntegration` but is
18
+ * the consumer-facing return type for `findByUserAndProvider`.
19
+ */
20
+ export interface DecryptedIntegrationRow {
21
+ id: string;
22
+ userId: string;
23
+ provider: string;
24
+ externalAccountId: string | null;
25
+ accessToken: string;
26
+ refreshToken: string | null;
27
+ expiresAt: Date | null;
28
+ scopes: string[] | null;
29
+ instanceUrl: string | null;
30
+ providerMetadata: Record<string, unknown> | null;
31
+ status: 'active' | 'requires_reauth' | 'revoked';
32
+ createdAt: Date;
33
+ updatedAt: Date;
34
+ }
35
+
36
+ /**
37
+ * IntegrationsService — consumer-facing facade over the codegen-emitted
38
+ * `IntegrationService` plus the auth subsystem's `IEncryptionKey`.
39
+ *
40
+ * Wider than the auth subsystem ports (`IIntegrationReader`,
41
+ * `IIntegrationTokenWriter`, `IIntegrationGrantSink`) on purpose: the
42
+ * narrow ports are the subsystem's hexagonal seam (so non-codegen
43
+ * consumers can implement them); the facade is what app code talks to
44
+ * directly (controllers, handlers, frontend-facing use cases).
45
+ *
46
+ * Same pattern as EAV's `FieldValueService.upsertFieldsTransactional`
47
+ * being wider than `IFieldValueRepository.upsertCurrentValues`.
48
+ *
49
+ * Ciphertexts never leave the facade in plaintext form except via
50
+ * `findByUserAndProvider` (server-side only — the caller is expected
51
+ * to use the tokens to make an outbound API call). `listByUser`
52
+ * intentionally strips them to a safe metadata shape.
53
+ */
54
+ @Injectable()
55
+ export class IntegrationsService {
56
+ constructor(
57
+ private readonly integrations: IntegrationService,
58
+ @Inject(ENCRYPTION_KEY) private readonly encryption: IEncryptionKey,
59
+ private readonly createOrUpdateUseCase: CreateOrUpdateFromOAuthGrantUseCase,
60
+ private readonly markReauthUseCase: MarkIntegrationRequiresReauthUseCase,
61
+ private readonly disconnectUseCase: DisconnectIntegrationUseCase,
62
+ private readonly listUseCase: ListUserIntegrationsUseCase,
63
+ ) {}
64
+
65
+ /**
66
+ * Loads the integration for `(userId, provider)` and returns it with
67
+ * decrypted tokens, or `null` if no row exists. Returns the row even
68
+ * if `status !== 'active'` so callers can distinguish "never
69
+ * connected" from "connected but broken" — gate on `status` yourself.
70
+ */
71
+ async findByUserAndProvider(
72
+ userId: string,
73
+ provider: string,
74
+ ): Promise<DecryptedIntegrationRow | null> {
75
+ const row = await this.integrations.findByUserIdAndProvider(userId, provider);
76
+ if (!row) return null;
77
+ return this.decrypt(row);
78
+ }
79
+
80
+ /**
81
+ * Lists a user's integrations newest-first, with ciphertexts stripped.
82
+ * Safe to return to a frontend.
83
+ */
84
+ async listByUser(userId: string): Promise<Array<Omit<Integration, 'accessTokenEncrypted' | 'refreshTokenEncrypted'>>> {
85
+ const rows = await this.listUseCase.execute(userId);
86
+ return rows.map((row) => {
87
+ const { accessTokenEncrypted: _accessTokenEncrypted, refreshTokenEncrypted: _refreshTokenEncrypted, ...safe } = row;
88
+ return safe;
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Upserts a freshly-minted OAuth2 grant from the authorize-code
94
+ * callback. Pass-through to `CreateOrUpdateFromOAuthGrantUseCase` —
95
+ * the input shape matches the auth subsystem's `IntegrationGrantInput`
96
+ * exactly so `IntegrationGrantSinkAdapter` can forward without
97
+ * mapping.
98
+ */
99
+ async createOrUpdateFromOAuthGrant(input: IntegrationGrantInput): Promise<void> {
100
+ await this.createOrUpdateUseCase.execute(input);
101
+ }
102
+
103
+ /**
104
+ * Flips status to `requires_reauth`. Called from `withAuthRetry`'s
105
+ * broken-integration handler.
106
+ */
107
+ async markRequiresReauth(integrationId: string): Promise<void> {
108
+ await this.markReauthUseCase.execute(integrationId);
109
+ }
110
+
111
+ /**
112
+ * User-initiated disconnect. Status → 'revoked', tokens cleared.
113
+ */
114
+ async disconnect(integrationId: string): Promise<void> {
115
+ await this.disconnectUseCase.execute(integrationId);
116
+ }
117
+
118
+ /**
119
+ * Decrypts ciphertexts on a raw `Integration` row. Used internally
120
+ * by `findByUserAndProvider` and by `IntegrationReaderAdapter`.
121
+ *
122
+ * Empty access tokens (e.g. revoked rows where the ciphertext was
123
+ * cleared) decrypt to the empty string — matches
124
+ * `DecryptedIntegration.accessToken`'s "empty if never granted"
125
+ * contract.
126
+ */
127
+ private async decrypt(row: Integration): Promise<DecryptedIntegrationRow> {
128
+ const accessToken = row.accessTokenEncrypted
129
+ ? await this.encryption.decrypt(row.accessTokenEncrypted)
130
+ : '';
131
+ const refreshToken = row.refreshTokenEncrypted
132
+ ? await this.encryption.decrypt(row.refreshTokenEncrypted)
133
+ : null;
134
+ return {
135
+ id: row.id,
136
+ userId: row.userId,
137
+ provider: row.provider,
138
+ externalAccountId: row.externalAccountId,
139
+ accessToken,
140
+ refreshToken,
141
+ expiresAt: row.expiresAt,
142
+ scopes: (row.scopes as string[] | null) ?? null,
143
+ instanceUrl: row.instanceUrl,
144
+ providerMetadata: (row.providerMetadata as Record<string, unknown> | null) ?? null,
145
+ status: row.status,
146
+ createdAt: row.createdAt,
147
+ updatedAt: row.updatedAt,
148
+ };
149
+ }
150
+ }
@@ -0,0 +1,81 @@
1
+ import { Global, Module } from '@nestjs/common';
2
+ import {
3
+ AUTH_INTEGRATION_GRANT_SINK,
4
+ AUTH_INTEGRATION_READER,
5
+ AUTH_INTEGRATION_TOKEN_WRITER,
6
+ } from '@pattern-stack/codegen/runtime/subsystems/auth';
7
+ import { IntegrationsModule } from './integrations.module';
8
+ import { IntegrationGrantSinkAdapter } from './adapters/integration-grant-sink.adapter';
9
+ import { IntegrationReaderAdapter } from './adapters/integration-reader.adapter';
10
+ import { IntegrationTokenWriterAdapter } from './adapters/integration-token-writer.adapter';
11
+ import { IntegrationsService } from './facade/integrations.service';
12
+ import { CreateOrUpdateFromOAuthGrantUseCase } from './oauth/use-cases/create-or-update-from-oauth-grant.use-case';
13
+ import { DisconnectIntegrationUseCase } from './oauth/use-cases/disconnect-integration.use-case';
14
+ import { ListUserIntegrationsUseCase } from './oauth/use-cases/list-user-integrations.use-case';
15
+ import { MarkIntegrationRequiresReauthUseCase } from './oauth/use-cases/mark-integration-requires-reauth.use-case';
16
+
17
+ /**
18
+ * `IntegrationsAuthModule` — wires the consumer-side adapters that
19
+ * satisfy the auth subsystem's three integration-store ports plus the
20
+ * `IntegrationsService` facade and its use cases.
21
+ *
22
+ * Imports `IntegrationsModule` (the codegen-emitted entity module) to
23
+ * pull in `IntegrationService` + its repository.
24
+ *
25
+ * Depends on a registered `ENCRYPTION_KEY` provider — that comes from
26
+ * `AuthModule.forRoot({ encryptionKey: ... })`. Make sure
27
+ * `AuthModule.forRoot(...)` is imported in your app's root module BEFORE
28
+ * `IntegrationsAuthModule` (or globally — `AuthModule` is `global: true`
29
+ * by convention).
30
+ *
31
+ * Token bindings (per #285 / #286):
32
+ * - AUTH_INTEGRATION_READER → IntegrationReaderAdapter
33
+ * - AUTH_INTEGRATION_TOKEN_WRITER → IntegrationTokenWriterAdapter
34
+ * - AUTH_INTEGRATION_GRANT_SINK → IntegrationGrantSinkAdapter
35
+ *
36
+ * `@Global()` is required: `AuthController` lives inside `AuthModule`'s
37
+ * own injector and resolves the `AUTH_INTEGRATION_*` providers exposed
38
+ * here. Without `@Global()`, the controller's injector cannot see these
39
+ * tokens and Nest fails to boot. Same pattern as the `auth-bindings`
40
+ * module shipped in #93.
41
+ */
42
+ @Global()
43
+ @Module({
44
+ imports: [IntegrationsModule],
45
+ providers: [
46
+ // Use cases (consumed by the facade)
47
+ CreateOrUpdateFromOAuthGrantUseCase,
48
+ MarkIntegrationRequiresReauthUseCase,
49
+ DisconnectIntegrationUseCase,
50
+ ListUserIntegrationsUseCase,
51
+
52
+ // Facade (consumer-facing API; controllers/handlers inject this)
53
+ IntegrationsService,
54
+
55
+ // Subsystem port adapters (concrete classes — also exposed under
56
+ // their token aliases for `@Inject(...)` consumers in the auth
57
+ // subsystem).
58
+ IntegrationReaderAdapter,
59
+ IntegrationTokenWriterAdapter,
60
+ IntegrationGrantSinkAdapter,
61
+ {
62
+ provide: AUTH_INTEGRATION_READER,
63
+ useExisting: IntegrationReaderAdapter,
64
+ },
65
+ {
66
+ provide: AUTH_INTEGRATION_TOKEN_WRITER,
67
+ useExisting: IntegrationTokenWriterAdapter,
68
+ },
69
+ {
70
+ provide: AUTH_INTEGRATION_GRANT_SINK,
71
+ useExisting: IntegrationGrantSinkAdapter,
72
+ },
73
+ ],
74
+ exports: [
75
+ IntegrationsService,
76
+ AUTH_INTEGRATION_READER,
77
+ AUTH_INTEGRATION_TOKEN_WRITER,
78
+ AUTH_INTEGRATION_GRANT_SINK,
79
+ ],
80
+ })
81
+ export class IntegrationsAuthModule {}
@@ -0,0 +1,75 @@
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+ import {
3
+ ENCRYPTION_KEY,
4
+ type IEncryptionKey,
5
+ type IntegrationGrantInput,
6
+ } from '@pattern-stack/codegen/runtime/subsystems/auth';
7
+ import { IntegrationService } from '../../integration.service';
8
+ import type { Integration } from '../../integration.entity';
9
+
10
+ /**
11
+ * Persists an OAuth2 grant from the authorize-code callback (initial
12
+ * connect or re-connect). Upserts on `(user_id, provider)`:
13
+ *
14
+ * - existing row → re-encrypt + persist tokens, status → 'active'
15
+ * - missing row → insert a new row in 'active' status
16
+ *
17
+ * The input shape is exactly `IntegrationGrantInput` from the auth
18
+ * subsystem so `IntegrationGrantSinkAdapter` can be a pass-through —
19
+ * the port and use case share the same boundary type. Encryption is
20
+ * applied here (inside the use case) before ciphertexts hit the row.
21
+ *
22
+ * Re-connect semantics: if the new grant omits `refreshToken`
23
+ * (provider didn't return one), the existing ciphertext is preserved
24
+ * — providers commonly omit the refresh token on a re-grant when
25
+ * they haven't rotated it.
26
+ */
27
+ @Injectable()
28
+ export class CreateOrUpdateFromOAuthGrantUseCase {
29
+ constructor(
30
+ private readonly integrations: IntegrationService,
31
+ @Inject(ENCRYPTION_KEY) private readonly encryption: IEncryptionKey,
32
+ ) {}
33
+
34
+ async execute(input: IntegrationGrantInput): Promise<Integration> {
35
+ const accessTokenEncrypted = await this.encryption.encrypt(input.accessToken);
36
+ const refreshTokenEncrypted =
37
+ input.refreshToken !== undefined
38
+ ? await this.encryption.encrypt(input.refreshToken)
39
+ : undefined;
40
+
41
+ const existing = await this.integrations.findByUserIdAndProvider(
42
+ input.userId,
43
+ input.provider,
44
+ );
45
+
46
+ const baseRow = {
47
+ userId: input.userId,
48
+ provider: input.provider,
49
+ accessTokenEncrypted,
50
+ expiresAt: input.expiresAt ?? null,
51
+ externalAccountId: input.externalAccountId ?? null,
52
+ scopes: input.scope ?? null,
53
+ providerMetadata: input.providerMetadata ?? null,
54
+ status: 'active' as const,
55
+ };
56
+
57
+ if (existing) {
58
+ // Preserve the existing refresh-token ciphertext when the grant
59
+ // didn't include a new refresh token (common on re-grants).
60
+ const patch: Partial<Integration> = {
61
+ ...baseRow,
62
+ refreshTokenEncrypted:
63
+ refreshTokenEncrypted !== undefined
64
+ ? refreshTokenEncrypted
65
+ : existing.refreshTokenEncrypted,
66
+ };
67
+ return this.integrations.update(existing.id, patch);
68
+ }
69
+
70
+ return this.integrations.create({
71
+ ...baseRow,
72
+ refreshTokenEncrypted: refreshTokenEncrypted ?? null,
73
+ });
74
+ }
75
+ }
@@ -0,0 +1,29 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { IntegrationService } from '../../integration.service';
3
+ import type { Integration } from '../../integration.entity';
4
+
5
+ /**
6
+ * User-initiated disconnect. Flips status to `revoked` and clears the
7
+ * stored ciphertexts so a leaked DB dump never re-grants access. The
8
+ * row is preserved (audit trail, FK integrity); a follow-up grant on
9
+ * the same `(user_id, provider)` will re-activate it via
10
+ * CreateOrUpdateFromOAuthGrantUseCase.
11
+ *
12
+ * Note: this does NOT call the provider's revoke endpoint. Providers
13
+ * vary widely on revoke API shapes — that step belongs in a
14
+ * provider-specific strategy if/when needed (out of scope for the
15
+ * starter).
16
+ */
17
+ @Injectable()
18
+ export class DisconnectIntegrationUseCase {
19
+ constructor(private readonly integrations: IntegrationService) {}
20
+
21
+ async execute(integrationId: string): Promise<Integration> {
22
+ return this.integrations.update(integrationId, {
23
+ status: 'revoked',
24
+ accessTokenEncrypted: null,
25
+ refreshTokenEncrypted: null,
26
+ expiresAt: null,
27
+ });
28
+ }
29
+ }