@xemahq/biome-database-nest 0.12.1 → 0.12.2
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 +4 -5
- package/src/index.ts +0 -115
- package/src/lib/adapter.ts +0 -117
- package/src/lib/bootstrap.ts +0 -428
- package/src/lib/config.ts +0 -183
- package/src/lib/control-plane-client.ts +0 -124
- package/src/lib/control-plane-discovery.ts +0 -42
- package/src/lib/identifier.ts +0 -177
- package/src/lib/migration-mode.ts +0 -25
- package/src/lib/nest/biome-database.module.ts +0 -174
- package/src/lib/nest/org-erasure.module.ts +0 -158
- package/src/lib/nest/prisma-service-factory.ts +0 -377
- package/src/lib/role-names.ts +0 -77
- package/src/lib/schema-name.ts +0 -62
- package/src/lib/tenant-isolation/org-erasure.ts +0 -235
- package/src/lib/tenant-isolation/org-scope.ts +0 -305
- package/src/lib/tenant-isolation/tenant-isolation-error.ts +0 -79
- package/src/lib/tenant-isolation/tenant-isolation-mode.ts +0 -55
- package/src/lib/tenant-isolation/tenant-isolation.extension.ts +0 -203
- package/src/lib/tenant-isolation/unscoped-models.ts +0 -272
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import { Module } from '@nestjs/common';
|
|
2
|
-
import { IdentityBootstrapService } from '@xemahq/identity-client';
|
|
3
|
-
import {
|
|
4
|
-
isOpenApiExtraction,
|
|
5
|
-
KERNEL_STATE_TOKEN,
|
|
6
|
-
} from '@xemahq/service-registry-nest';
|
|
7
|
-
|
|
8
|
-
import { bootstrapBiomeDatabases } from '../bootstrap';
|
|
9
|
-
import { BiomeDatabaseMigrationMode } from '../migration-mode';
|
|
10
|
-
|
|
11
|
-
import type {
|
|
12
|
-
BiomeDatabaseDeclaration,
|
|
13
|
-
} from '../bootstrap';
|
|
14
|
-
import type { DynamicModule } from '@nestjs/common';
|
|
15
|
-
import type { KernelState } from '@xemahq/kernel-contracts/kernel-state';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* DI-resolved sentinel that resolves to `true` once every declared biome
|
|
19
|
-
* database has been schema-created, migrated, and exported into the env that
|
|
20
|
-
* `createBiomePrismaAdapterFromEnv` reads.
|
|
21
|
-
*
|
|
22
|
-
* A consuming service MUST list this token in the `inject:[]` of its
|
|
23
|
-
* `PrismaService` provider so Nest constructs the database connection ONLY
|
|
24
|
-
* after the bootstrap has completed.
|
|
25
|
-
*/
|
|
26
|
-
export const BIOME_DATABASE_READY = Symbol.for(
|
|
27
|
-
'@xemahq/biome-database-nest:ready',
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
interface BiomeDatabaseModuleCommonOptions {
|
|
31
|
-
/** The biome whose databases are being bootstrapped. */
|
|
32
|
-
readonly biomeId: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface ApplicationManagedBiomeDatabaseModuleOptions
|
|
36
|
-
extends BiomeDatabaseModuleCommonOptions {
|
|
37
|
-
readonly migrationMode: BiomeDatabaseMigrationMode.ApplicationManaged;
|
|
38
|
-
/**
|
|
39
|
-
* Explicit databases to bootstrap. Each declaration pins the credential
|
|
40
|
-
* generation independently of migration ownership.
|
|
41
|
-
*/
|
|
42
|
-
readonly databases: readonly BiomeDatabaseDeclaration[];
|
|
43
|
-
/**
|
|
44
|
-
* Postgres extensions this biome needs (e.g. `['vector']`), ensured in the
|
|
45
|
-
* shared `public` schema BEFORE migrations run. Only declare extensions the
|
|
46
|
-
* biome actually uses. See `BootstrapBiomeDatabasesOptions.requiredExtensions`.
|
|
47
|
-
*/
|
|
48
|
-
readonly requiredExtensions?: readonly string[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
/** Options for {@link BiomeDatabaseModule.forRoot}. */
|
|
53
|
-
export type BiomeDatabaseModuleOptions =
|
|
54
|
-
ApplicationManagedBiomeDatabaseModuleOptions;
|
|
55
|
-
|
|
56
|
-
function assertBiomeDatabaseModuleOptions(
|
|
57
|
-
opts: unknown,
|
|
58
|
-
): asserts opts is BiomeDatabaseModuleOptions {
|
|
59
|
-
if (
|
|
60
|
-
opts === null ||
|
|
61
|
-
typeof opts !== 'object' ||
|
|
62
|
-
!('migrationMode' in opts) ||
|
|
63
|
-
opts.migrationMode !== BiomeDatabaseMigrationMode.ApplicationManaged
|
|
64
|
-
) {
|
|
65
|
-
throw new Error(
|
|
66
|
-
'[biome-database] migrationMode is required and must be ApplicationManaged.',
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
!('databases' in opts) ||
|
|
72
|
-
!Array.isArray(opts.databases) ||
|
|
73
|
-
opts.databases.length === 0
|
|
74
|
-
) {
|
|
75
|
-
throw new Error(
|
|
76
|
-
'[biome-database] every migration mode requires at least one explicit database declaration.',
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Global Nest dynamic module that bootstraps a biome's database using an
|
|
83
|
-
* explicitly selected migration ownership mode.
|
|
84
|
-
*
|
|
85
|
-
* Application-managed mode retains env-first config resolution. Externally
|
|
86
|
-
* managed declarations select environment or control plane explicitly, so a
|
|
87
|
-
* stale env override can never silently replace the intended runtime
|
|
88
|
-
* credential.
|
|
89
|
-
*
|
|
90
|
-
* `IdentityBootstrapService` (from `@xemahq/identity-client`) is provided
|
|
91
|
-
* globally by the app's `XemaServiceModule.forBiome(...)` bootstrap, so it is
|
|
92
|
-
* injectable at DI time and mints the service bearer for the control-plane
|
|
93
|
-
* call. The control-plane ADDRESS is discovered from the Xema service registry:
|
|
94
|
-
* this factory injects the app's `KernelState` backend via `KERNEL_STATE_TOKEN`
|
|
95
|
-
* (exposed globally by `ServiceRegistryModule`, wired by
|
|
96
|
-
* `XemaServiceModule.forBiome(...)`) and drives a private, short-lived
|
|
97
|
-
* `KernelStateServiceRegistry` to `resolve('org-database-pool-api')` with
|
|
98
|
-
* bounded retry — see `resolveControlPlaneBaseUrl` in `control-plane-discovery`.
|
|
99
|
-
* The app-wide registry is NOT injected: it hydrates only in `onModuleInit`,
|
|
100
|
-
* which cannot run until this instantiation-phase factory returns.
|
|
101
|
-
*
|
|
102
|
-
* REQUIRES the consuming app to provide `KERNEL_STATE_TOKEN` globally — every
|
|
103
|
-
* biome service does via `XemaServiceModule.forBiome(...)` /
|
|
104
|
-
* `ServiceRegistryModule`. No extra import in this module is needed because that
|
|
105
|
-
* provider is global; if it is absent, Nest fails fast at DI resolution.
|
|
106
|
-
*
|
|
107
|
-
* Fail-fast: an absent/unknown mode or any boot error aborts Nest startup.
|
|
108
|
-
*
|
|
109
|
-
* @example Consumer wiring
|
|
110
|
-
* ```ts
|
|
111
|
-
* // app.module.ts (or prisma.module.ts):
|
|
112
|
-
* imports: [BiomeDatabaseModule.forRoot({
|
|
113
|
-
* biomeId: '<biome-id>',
|
|
114
|
-
* })],
|
|
115
|
-
*
|
|
116
|
-
* // prisma.module.ts — the PrismaService provider waits on the boot:
|
|
117
|
-
* {
|
|
118
|
-
* provide: PrismaService,
|
|
119
|
-
* useFactory: () => new PrismaService(),
|
|
120
|
-
* inject: [BIOME_DATABASE_READY],
|
|
121
|
-
* }
|
|
122
|
-
* ```
|
|
123
|
-
*/
|
|
124
|
-
@Module({})
|
|
125
|
-
export class BiomeDatabaseModule {
|
|
126
|
-
static forRoot(opts: BiomeDatabaseModuleOptions): DynamicModule;
|
|
127
|
-
static forRoot(opts: unknown): DynamicModule {
|
|
128
|
-
assertBiomeDatabaseModuleOptions(opts);
|
|
129
|
-
const databases: readonly BiomeDatabaseDeclaration[] = opts.databases;
|
|
130
|
-
|
|
131
|
-
// Build-time OpenAPI extraction compiles the DI graph for decorator
|
|
132
|
-
// metadata only: it never calls `app.init()`, never issues a query, and
|
|
133
|
-
// stubs `PrismaService` outright. Running the real bootstrap there would
|
|
134
|
-
// connect to — and MIGRATE — a throwaway scratch database, which is both
|
|
135
|
-
// pointless and (under parallel extraction) mutually destructive. Resolve
|
|
136
|
-
// the sentinel to `true` without injecting anything.
|
|
137
|
-
if (isOpenApiExtraction()) {
|
|
138
|
-
return {
|
|
139
|
-
module: BiomeDatabaseModule,
|
|
140
|
-
global: true,
|
|
141
|
-
providers: [{ provide: BIOME_DATABASE_READY, useValue: true }],
|
|
142
|
-
exports: [BIOME_DATABASE_READY],
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
module: BiomeDatabaseModule,
|
|
148
|
-
global: true,
|
|
149
|
-
providers: [
|
|
150
|
-
{
|
|
151
|
-
provide: BIOME_DATABASE_READY,
|
|
152
|
-
useFactory: async (
|
|
153
|
-
identityBootstrap: IdentityBootstrapService,
|
|
154
|
-
kernelState: KernelState,
|
|
155
|
-
): Promise<true> => {
|
|
156
|
-
await bootstrapBiomeDatabases({
|
|
157
|
-
biomeId: opts.biomeId,
|
|
158
|
-
migrationMode: opts.migrationMode,
|
|
159
|
-
databases,
|
|
160
|
-
...(opts.requiredExtensions !== undefined
|
|
161
|
-
? { requiredExtensions: opts.requiredExtensions }
|
|
162
|
-
: {}),
|
|
163
|
-
serviceToken: () => identityBootstrap.getAccessToken(),
|
|
164
|
-
kernelState,
|
|
165
|
-
});
|
|
166
|
-
return true;
|
|
167
|
-
},
|
|
168
|
-
inject: [IdentityBootstrapService, KERNEL_STATE_TOKEN],
|
|
169
|
-
},
|
|
170
|
-
],
|
|
171
|
-
exports: [BIOME_DATABASE_READY],
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
}
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LC-1 topology-1 org-erasure ENDPOINT — the DRY adoption surface.
|
|
3
|
-
*
|
|
4
|
-
* A first-party biome that holds org data by-row opts in with ONE line:
|
|
5
|
-
*
|
|
6
|
-
* ```ts
|
|
7
|
-
* imports: [OrgErasureModule.forRoot({ prismaServiceToken: PrismaService })]
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* That exposes the STANDARD internal route `POST /internal/org-erasure/:orgId`,
|
|
11
|
-
* which erases every row belonging to the org across the service's org-scoped
|
|
12
|
-
* models via {@link eraseOrgData} on the UNSCOPED client (`asSystem()`). The
|
|
13
|
-
* org-erasure orchestrator saga fans out to this route on every participant.
|
|
14
|
-
*
|
|
15
|
-
* Service-only, and ENFORCED by `ServiceActorGuard` — not by the classification
|
|
16
|
-
* decorator. `@XemaInternalRoute({ audience: Service })` is metadata the
|
|
17
|
-
* fail-closed route scanner reads; it installs NO runtime guard (see
|
|
18
|
-
* `.claude/rules/public-and-internal-route-guarding.md`). The global stack only
|
|
19
|
-
* proves the JWT signature, and `RolesGuard` enforces nothing unless a route
|
|
20
|
-
* declares `@RequireOrgRole`/`@RequireTokenClass`. Without the guard, ANY
|
|
21
|
-
* authenticated user token reaching this service — trivially so for any
|
|
22
|
-
* participant whose chart enables ingress — could irreversibly erase an
|
|
23
|
-
* arbitrary org's data, since `:orgId` is taken verbatim from the path. This
|
|
24
|
-
* module is adopted by 17 participants, so the guard belongs HERE, once.
|
|
25
|
-
*
|
|
26
|
-
* The org filter is applied inside {@link eraseOrgData}; the runner passes the
|
|
27
|
-
* unscoped client so scoping is neither doubled nor left to ambient context.
|
|
28
|
-
*
|
|
29
|
-
* **The module refuses to boot a participant that can erase nothing.** Wiring
|
|
30
|
-
* the runner asserts (via {@link assertOrgErasureMatchesModels}) that the
|
|
31
|
-
* configured `orgField` matches at least one model of THIS service's schema. A
|
|
32
|
-
* participant registered against a column its schema does not spell matches
|
|
33
|
-
* zero models, deletes nothing, and returns `{totalDeleted: 0}` — which the
|
|
34
|
-
* orchestrator records as a successful erasure. That must fail at deploy, not
|
|
35
|
-
* silently succeed during a Data Subject Request.
|
|
36
|
-
*/
|
|
37
|
-
import {
|
|
38
|
-
Controller,
|
|
39
|
-
Inject,
|
|
40
|
-
Logger,
|
|
41
|
-
Param,
|
|
42
|
-
Post,
|
|
43
|
-
UseGuards,
|
|
44
|
-
type DynamicModule,
|
|
45
|
-
type Type,
|
|
46
|
-
} from '@nestjs/common';
|
|
47
|
-
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
48
|
-
import { ServiceActorGuard } from '@xemahq/platform-common';
|
|
49
|
-
import { InternalRouteAudience, XemaInternalRoute } from '@xemahq/xema-decorators';
|
|
50
|
-
|
|
51
|
-
import {
|
|
52
|
-
assertOrgErasureMatchesModels,
|
|
53
|
-
eraseOrgData,
|
|
54
|
-
type EraseOrgDataOptions,
|
|
55
|
-
type OrgErasureReport,
|
|
56
|
-
} from '../tenant-isolation/org-erasure';
|
|
57
|
-
import { DEFAULT_ORG_FIELD } from '../tenant-isolation/tenant-isolation.extension';
|
|
58
|
-
|
|
59
|
-
import type { TenantScopedApi } from './prisma-service-factory';
|
|
60
|
-
|
|
61
|
-
/** DI token for the erasure runner the controller delegates to. */
|
|
62
|
-
export const ORG_ERASURE_RUNNER = Symbol.for(
|
|
63
|
-
'@xemahq/biome-database-nest/org-erasure-runner',
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The capability a topology-1 participant declares in its service descriptor's
|
|
68
|
-
* `exposesCapabilities` so the org-erasure orchestrator can DISCOVER it via the
|
|
69
|
-
* service registry (`registry.list()` filtered on this string) and fan out to
|
|
70
|
-
* its `POST /internal/org-erasure/:orgId`. Single source of truth shared by the
|
|
71
|
-
* declaring service and the orchestrator — discovery is explicit-by-declaration,
|
|
72
|
-
* never a probe-and-guess heuristic.
|
|
73
|
-
*/
|
|
74
|
-
export const ORG_DATA_ERASURE_CAPABILITY = 'xema.org-data-erasure';
|
|
75
|
-
|
|
76
|
-
/** The internal route path this module exposes (orchestrator fans out to it). */
|
|
77
|
-
export const ORG_ERASURE_ROUTE_PATH = 'internal/org-erasure';
|
|
78
|
-
|
|
79
|
-
export interface OrgErasureRunner {
|
|
80
|
-
erase(orgId: string): Promise<OrgErasureReport>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
@ApiTags('org-erasure')
|
|
84
|
-
@UseGuards(ServiceActorGuard)
|
|
85
|
-
@Controller('internal/org-erasure')
|
|
86
|
-
export class OrgErasureController {
|
|
87
|
-
constructor(
|
|
88
|
-
@Inject(ORG_ERASURE_RUNNER) private readonly runner: OrgErasureRunner,
|
|
89
|
-
) {}
|
|
90
|
-
|
|
91
|
-
@XemaInternalRoute({ audience: InternalRouteAudience.Service })
|
|
92
|
-
@Post(':orgId')
|
|
93
|
-
@ApiOperation({
|
|
94
|
-
summary:
|
|
95
|
-
"Erase every row belonging to an org across this service's org-scoped models",
|
|
96
|
-
})
|
|
97
|
-
erase(@Param('orgId') orgId: string): Promise<OrgErasureReport> {
|
|
98
|
-
return this.runner.erase(orgId);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export interface OrgErasureModuleOptions extends EraseOrgDataOptions {
|
|
103
|
-
/**
|
|
104
|
-
* The service's PrismaService DI token — the class it provides as its Prisma
|
|
105
|
-
* service (a {@link BiomePrismaServiceInstance}). The runner resolves it and
|
|
106
|
-
* calls `.asSystem()` to get the unscoped client.
|
|
107
|
-
*/
|
|
108
|
-
readonly prismaServiceToken: Type<unknown> | string | symbol;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The subset of the biome Prisma service the runner needs: the unscoped
|
|
113
|
-
* `asSystem()` client accessor.
|
|
114
|
-
*/
|
|
115
|
-
type AsSystemProvider = TenantScopedApi<unknown>;
|
|
116
|
-
|
|
117
|
-
export class OrgErasureModule {
|
|
118
|
-
static forRoot(options: OrgErasureModuleOptions): DynamicModule {
|
|
119
|
-
const { prismaServiceToken, ...eraseOptions } = options;
|
|
120
|
-
return {
|
|
121
|
-
module: OrgErasureModule,
|
|
122
|
-
controllers: [OrgErasureController],
|
|
123
|
-
providers: [
|
|
124
|
-
{
|
|
125
|
-
provide: ORG_ERASURE_RUNNER,
|
|
126
|
-
useFactory: (prisma: AsSystemProvider): OrgErasureRunner => {
|
|
127
|
-
const client = prisma.asSystem();
|
|
128
|
-
// BOOT GATE. A participant whose orgField matches no model can
|
|
129
|
-
// never delete a row, and `eraseOrgData` would have answered every
|
|
130
|
-
// erasure `{totalDeleted: 0}` — which the orchestrator saga records
|
|
131
|
-
// as a successful erasure. Discovering that during a live Data
|
|
132
|
-
// Subject Request is discovering it too late, so the service
|
|
133
|
-
// refuses to START rather than advertise `xema.org-data-erasure`
|
|
134
|
-
// it cannot honour. Throwing here aborts Nest's bootstrap.
|
|
135
|
-
//
|
|
136
|
-
// This is the CONFIGURATION proof; `eraseOrgData` re-asserts per
|
|
137
|
-
// request, which is the proof of the RUN being recorded as
|
|
138
|
-
// evidence. The assertion is on MODELS matched — an org that
|
|
139
|
-
// genuinely holds no rows here still erases successfully.
|
|
140
|
-
const models = [
|
|
141
|
-
...assertOrgErasureMatchesModels(client, eraseOptions),
|
|
142
|
-
].sort();
|
|
143
|
-
new Logger(OrgErasureModule.name).log(
|
|
144
|
-
`org-erasure participant armed on "${
|
|
145
|
-
eraseOptions.orgField ?? DEFAULT_ORG_FIELD
|
|
146
|
-
}" over ${models.length} model(s): ${models.join(', ')}`,
|
|
147
|
-
);
|
|
148
|
-
return {
|
|
149
|
-
erase: (orgId: string): Promise<OrgErasureReport> =>
|
|
150
|
-
eraseOrgData(prisma.asSystem(), orgId, eraseOptions),
|
|
151
|
-
};
|
|
152
|
-
},
|
|
153
|
-
inject: [prismaServiceToken],
|
|
154
|
-
},
|
|
155
|
-
],
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
}
|