@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.
- package/CHANGELOG.md +27 -0
- package/dist/src/cli/index.js +55 -19
- package/dist/src/cli/index.js.map +1 -1
- package/examples/auth-integrations/README.md +125 -0
- package/examples/auth-integrations/definitions/entities/integration.yaml +98 -0
- package/examples/auth-integrations/runtime/integrations/adapters/integration-grant-sink.adapter.ts +29 -0
- package/examples/auth-integrations/runtime/integrations/adapters/integration-reader.adapter.ts +52 -0
- package/examples/auth-integrations/runtime/integrations/adapters/integration-token-writer.adapter.ts +43 -0
- package/examples/auth-integrations/runtime/integrations/facade/integrations.service.ts +150 -0
- package/examples/auth-integrations/runtime/integrations/integrations-auth.module.ts +81 -0
- package/examples/auth-integrations/runtime/integrations/oauth/use-cases/create-or-update-from-oauth-grant.use-case.ts +75 -0
- package/examples/auth-integrations/runtime/integrations/oauth/use-cases/disconnect-integration.use-case.ts +29 -0
- package/examples/auth-integrations/runtime/integrations/oauth/use-cases/list-user-integrations.use-case.ts +21 -0
- package/examples/auth-integrations/runtime/integrations/oauth/use-cases/mark-integration-requires-reauth.use-case.ts +21 -0
- package/package.json +2 -1
- package/templates/entity/new/clean-lite-ps/entity.ejs.t +6 -0
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +36 -4
|
@@ -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
|
package/examples/auth-integrations/runtime/integrations/adapters/integration-grant-sink.adapter.ts
ADDED
|
@@ -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
|
+
}
|
package/examples/auth-integrations/runtime/integrations/adapters/integration-reader.adapter.ts
ADDED
|
@@ -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
|
+
}
|
package/examples/auth-integrations/runtime/integrations/adapters/integration-token-writer.adapter.ts
ADDED
|
@@ -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
|
+
}
|