@velajs/tenant 1.24.1
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 +54 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/d1/index.d.ts +26 -0
- package/dist/d1/index.js +47 -0
- package/dist/d1/index.js.map +1 -0
- package/dist/durable-objects/index.d.ts +18 -0
- package/dist/durable-objects/index.js +38 -0
- package/dist/durable-objects/index.js.map +1 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.js +225 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres/index.d.ts +31 -0
- package/dist/postgres/index.js +69 -0
- package/dist/postgres/index.js.map +1 -0
- package/dist/storage-types-CoSKssiV.d.ts +29 -0
- package/dist/vela/index.d.ts +27 -0
- package/dist/vela/index.js +133 -0
- package/dist/vela/index.js.map +1 -0
- package/package.json +90 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @velajs/tenant
|
|
2
|
+
|
|
3
|
+
## 1.24.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c5a3cb0: Preserve authentication payload through verified tenant admission while retaining
|
|
8
|
+
invalidation on expiry, clear and reauthentication. Add explicit HTTP-backed
|
|
9
|
+
execution-context identity binding for custom dispatchers, and add a redacting
|
|
10
|
+
Secret value with runtime-private signing
|
|
11
|
+
credentials. Existing authentication and signing entrypoints remain compatible.
|
|
12
|
+
- Updated dependencies [c6a43a6]
|
|
13
|
+
- Updated dependencies [bbe62d4]
|
|
14
|
+
- Updated dependencies [a6ef933]
|
|
15
|
+
- Updated dependencies [dae3654]
|
|
16
|
+
- Updated dependencies [77cca9e]
|
|
17
|
+
- Updated dependencies [b9f75f5]
|
|
18
|
+
- Updated dependencies [df47ea8]
|
|
19
|
+
- Updated dependencies [af019bf]
|
|
20
|
+
- Updated dependencies [6df1059]
|
|
21
|
+
- Updated dependencies [bdd90a1]
|
|
22
|
+
- Updated dependencies [8a3923f]
|
|
23
|
+
- Updated dependencies [c7d108b]
|
|
24
|
+
- Updated dependencies [1c7f635]
|
|
25
|
+
- Updated dependencies [636ffbc]
|
|
26
|
+
- Updated dependencies [54f8864]
|
|
27
|
+
- Updated dependencies [f49db45]
|
|
28
|
+
- Updated dependencies [4fde903]
|
|
29
|
+
- Updated dependencies [6a1b5b3]
|
|
30
|
+
- Updated dependencies [a95951a]
|
|
31
|
+
- Updated dependencies [9e82187]
|
|
32
|
+
- Updated dependencies [c5a3cb0]
|
|
33
|
+
- Updated dependencies [363fb71]
|
|
34
|
+
- Updated dependencies [de4e57e]
|
|
35
|
+
- Updated dependencies [0765aaa]
|
|
36
|
+
- Updated dependencies [6b7cf23]
|
|
37
|
+
- Updated dependencies [5205e58]
|
|
38
|
+
- Updated dependencies [ae45689]
|
|
39
|
+
- @velajs/vela@1.25.0
|
|
40
|
+
|
|
41
|
+
## 1.24.0
|
|
42
|
+
|
|
43
|
+
### Minor Changes
|
|
44
|
+
|
|
45
|
+
- fe7587f: Add Standard Schema validation and operation contracts, authoritative tenant admission and audited persistence, optional Cedar authorization with exact query plans, and Web Crypto envelope/field/file encryption. Complete compound CRUD identifiers, parent scopes, structured predicates, signed cursors, page projections and post-commit delivery. Add transactional memory and Durable Object SQLite adapters, with D1/Workers and PostgreSQL conformance coverage.
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- Updated dependencies [fe7587f]
|
|
50
|
+
- @velajs/vela@1.24.0
|
|
51
|
+
|
|
52
|
+
## Unreleased
|
|
53
|
+
|
|
54
|
+
- Add the portable edge capability and optional integrations described in the package README.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ksh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @velajs/tenant
|
|
2
|
+
|
|
3
|
+
Authoritative tenant admission and audited lifecycle management on Web APIs. The
|
|
4
|
+
root export is independent of Vela. Import `/vela`, `/d1`, `/postgres`, or
|
|
5
|
+
`/durable-objects` only when needed.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { TenantService } from '@velajs/tenant';
|
|
9
|
+
import { D1TenantRegistryStore } from '@velajs/tenant/d1';
|
|
10
|
+
|
|
11
|
+
const tenants = new TenantService({
|
|
12
|
+
lookup: new D1TenantRegistryStore(env.DB),
|
|
13
|
+
authorize: ({ tenant, principal }) => memberships.authorize(tenant.id, principal),
|
|
14
|
+
});
|
|
15
|
+
await tenants.run({ tenantId: selector, principal: verifiedPrincipal }, async tenant => {
|
|
16
|
+
return documents.execute('list', { tenant });
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
A selector is never authority. Admission canonicalizes the ID, reads the
|
|
21
|
+
registry, requires active status and membership authorization, then checks that
|
|
22
|
+
the authoritative revision has not changed during admission. The principal must
|
|
23
|
+
come from verified credentials. Supply an authoritative membership implementation;
|
|
24
|
+
positive caches require authoritative versions. A failed lookup or authorization
|
|
25
|
+
store fails the operation. Operations admitted before suspension may finish.
|
|
26
|
+
|
|
27
|
+
Snapshots and JSON settings are immutable. `run()` closes its capability when the
|
|
28
|
+
callback settles, including errors. `parent` scopes cannot change tenant or
|
|
29
|
+
principal. Construct services and stores per application/environment.
|
|
30
|
+
|
|
31
|
+
## Vela and event integration
|
|
32
|
+
|
|
33
|
+
Import `TenantModule`, `TenantGuard`, `TenantRequired`, `TenantOptional`,
|
|
34
|
+
`TenantIgnored`, `CurrentTenant`, and `TENANT_CONTEXT_READER` from `/vela`.
|
|
35
|
+
Register `TenantModule.forRoot({ lookup, authorize })` (or `forRootAsync`) and use
|
|
36
|
+
`@UseGuards(TenantGuard)` after authentication. Required is the guard's default;
|
|
37
|
+
optional permits absence, but an explicit selector still requires authentication.
|
|
38
|
+
Ignored routes do not admit a tenant. Conflicting authenticated tenant IDs fail.
|
|
39
|
+
The guard publishes the canonical tenant into the trusted identity and CRUD's
|
|
40
|
+
compatibility variable. For generated tenant CRUD controllers, retain
|
|
41
|
+
`tenantResolverMounted: true` and apply the guard to the controller/module.
|
|
42
|
+
|
|
43
|
+
For a queue message, scheduled tenant, or WebSocket operation:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { runInTenantScope, TENANT_CONTEXT_READER } from '@velajs/tenant/vela';
|
|
47
|
+
await runInTenantScope(app.getContainer(), {
|
|
48
|
+
tenantId: message.tenantId, // selector only
|
|
49
|
+
principal: verifiedServicePrincipal,
|
|
50
|
+
source: 'queue',
|
|
51
|
+
}, async (container, tenant) => {
|
|
52
|
+
// A fresh request-scoped child container; no HTTP context or AsyncLocalStorage.
|
|
53
|
+
const reader = container.resolve(TENANT_CONTEXT_READER);
|
|
54
|
+
return processMessage(message, reader);
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Pass a declaring module ID when the application imports multiple tenant service
|
|
59
|
+
configurations. Each operation disposes its reader. A batch with several tenants
|
|
60
|
+
must admit each message independently. Durable Object routing is application
|
|
61
|
+
owned: choose a canonical tenant/object key and admit inside the addressed object.
|
|
62
|
+
|
|
63
|
+
Standalone Hono CRUD can retain `multiTenant` from `@velajs/crud/multi-tenant`:
|
|
64
|
+
use `admission: tenantAdmission(service, resolveVerifiedPrincipal)` from this
|
|
65
|
+
package. The adapter performs authoritative admission and returns the canonical
|
|
66
|
+
ID; authentication remains the resolver's responsibility.
|
|
67
|
+
|
|
68
|
+
## Registry and persistence
|
|
69
|
+
|
|
70
|
+
`TenantRegistry.save(record, expectedRevision, principal, reason)` creates with
|
|
71
|
+
`null` or updates using a revision compare-and-set. Set `status: 'suspended'` to
|
|
72
|
+
suspend, or `active` to resume. Registry administration has its own mandatory
|
|
73
|
+
authorization callback. Settings changes, status changes, and their audit records
|
|
74
|
+
commit atomically. Concurrent stale edits throw `TENANT_CONFLICT`.
|
|
75
|
+
|
|
76
|
+
`runForTenant(id, principal, reason, callback)` appends a durable administrative
|
|
77
|
+
audit before exposing a targeted administrative scope. Only `requireTenantTarget`
|
|
78
|
+
works on that scope; it cannot become ordinary CRUD/crypto tenant authority.
|
|
79
|
+
Use a separate privileged repository/credential inside administrative callbacks.
|
|
80
|
+
There is no global bypass flag. Directory `store.list({ after, limit })` and raw
|
|
81
|
+
store writes are infrastructure APIs: authorize their callers.
|
|
82
|
+
|
|
83
|
+
| Adapter | Initialization and boundary |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| D1 | Apply `tenantSqliteSchema`; use the primary database binding. CAS and audit use one atomic SQL batch. |
|
|
86
|
+
| PostgreSQL | Apply `tenantPostgresSchema`; pass a primary, cache-disabled client. CAS and audit use one statement. |
|
|
87
|
+
| Durable Objects | Call `store.migrate()` in the SQLite object. CAS and audit use `transactionSync`. |
|
|
88
|
+
| Memory | `MemoryTenantRegistryStore` owns its records and audits; useful for tests. |
|
|
89
|
+
|
|
90
|
+
`withTenantRls(executor, tenant, callback)` sets `vela.tenant_id` locally on the
|
|
91
|
+
same PostgreSQL transaction. `tenantRlsStatements(table, column)` enables and
|
|
92
|
+
forces matching USING/WITH CHECK policies. Run ordinary traffic under a role
|
|
93
|
+
without superuser/BYPASSRLS; keep administrative credentials separate.
|
|
94
|
+
|
|
95
|
+
Do not use KV, background polling, replica reads, or Hyperdrive query caches as
|
|
96
|
+
admission authority. Native bindings, SQL migrations, and membership stores are
|
|
97
|
+
application dependencies, never global framework state.
|
|
98
|
+
|
|
99
|
+
## Authentication composition
|
|
100
|
+
|
|
101
|
+
Run AuthGuard or CloudflareAccessGuard before TenantGuard, then permission guards and throttling. Admission preserves authentication provider payload while publishing a new immutable tenant-bound identity. Clearing, replacement, or expiry invalidates both identity payload and the admitted request reader. For concurrent HTTP-backed resolver fields, admit the tenant once at the outer HTTP boundary and consume the request reader in each field.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { t as D1Binding } from "../storage-types-CoSKssiV.js";
|
|
2
|
+
import { TenantAuditEvent, TenantRecord, TenantRegistryStore } from "../index.js";
|
|
3
|
+
//#region src/d1/index.d.ts
|
|
4
|
+
export declare const tenantSqliteSchema: readonly ["CREATE TABLE IF NOT EXISTS vela_tenants (id TEXT PRIMARY KEY, name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended')), revision INTEGER NOT NULL, settings TEXT NOT NULL)", 'CREATE TABLE IF NOT EXISTS vela_tenant_audit (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, event TEXT NOT NULL)'];
|
|
5
|
+
export declare function decodeTenantRow(row: Record<string, unknown>): TenantRecord;
|
|
6
|
+
export declare function directoryOptions(options?: {
|
|
7
|
+
after?: string;
|
|
8
|
+
limit?: number;
|
|
9
|
+
}): {
|
|
10
|
+
after: string;
|
|
11
|
+
limit: number;
|
|
12
|
+
};
|
|
13
|
+
/** Admission uses the primary binding, never a replica session or KV snapshot. */
|
|
14
|
+
export declare class D1TenantRegistryStore implements TenantRegistryStore {
|
|
15
|
+
private readonly database;
|
|
16
|
+
constructor(database: D1Binding);
|
|
17
|
+
get(id: string): Promise<TenantRecord | null>;
|
|
18
|
+
list(options?: {
|
|
19
|
+
after?: string;
|
|
20
|
+
limit?: number;
|
|
21
|
+
}): Promise<readonly TenantRecord[]>;
|
|
22
|
+
put(input: TenantRecord, expected: number | null, event: TenantAuditEvent): Promise<boolean>;
|
|
23
|
+
appendAudit(event: TenantAuditEvent): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/d1/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { parseTenant, tenantId } from "../index.js";
|
|
2
|
+
//#region src/d1/index.ts
|
|
3
|
+
const tenantSqliteSchema = ["CREATE TABLE IF NOT EXISTS vela_tenants (id TEXT PRIMARY KEY, name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended')), revision INTEGER NOT NULL, settings TEXT NOT NULL)", "CREATE TABLE IF NOT EXISTS vela_tenant_audit (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, event TEXT NOT NULL)"];
|
|
4
|
+
function decodeTenantRow(row) {
|
|
5
|
+
if (typeof row.settings !== "string") throw new TypeError("Invalid tenant settings column");
|
|
6
|
+
return parseTenant({
|
|
7
|
+
...row,
|
|
8
|
+
settings: JSON.parse(row.settings)
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
function directoryOptions(options = {}) {
|
|
12
|
+
const limit = options.limit ?? 100;
|
|
13
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e3) throw new TypeError("Invalid directory limit");
|
|
14
|
+
return {
|
|
15
|
+
after: options.after ?? "",
|
|
16
|
+
limit
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Admission uses the primary binding, never a replica session or KV snapshot. */
|
|
20
|
+
var D1TenantRegistryStore = class {
|
|
21
|
+
database;
|
|
22
|
+
constructor(database) {
|
|
23
|
+
this.database = database;
|
|
24
|
+
}
|
|
25
|
+
async get(id) {
|
|
26
|
+
const row = await this.database.prepare("SELECT * FROM vela_tenants WHERE id = ?").bind(tenantId(id)).first();
|
|
27
|
+
return row ? decodeTenantRow(row) : null;
|
|
28
|
+
}
|
|
29
|
+
async list(options) {
|
|
30
|
+
const { after, limit } = directoryOptions(options);
|
|
31
|
+
return (await this.database.prepare("SELECT * FROM vela_tenants WHERE id > ? ORDER BY id LIMIT ?").bind(after, limit).all()).results.map(decodeTenantRow);
|
|
32
|
+
}
|
|
33
|
+
async put(input, expected, event) {
|
|
34
|
+
const record = parseTenant(input);
|
|
35
|
+
if (record.revision !== (expected ?? 0) + 1 || event.tenantId !== record.id) throw new TypeError("Invalid tenant transition");
|
|
36
|
+
const mutation = expected === null ? this.database.prepare("INSERT INTO vela_tenants(id,name,status,revision,settings) VALUES(?,?,?,?,?) ON CONFLICT(id) DO NOTHING").bind(record.id, record.name, record.status, record.revision, JSON.stringify(record.settings)) : this.database.prepare("UPDATE vela_tenants SET name=?,status=?,revision=?,settings=? WHERE id=? AND revision=?").bind(record.name, record.status, record.revision, JSON.stringify(record.settings), record.id, expected);
|
|
37
|
+
const [result] = await this.database.batch([mutation, this.database.prepare("INSERT INTO vela_tenant_audit(id,tenant_id,event) SELECT ?,?,? WHERE changes() = 1").bind(event.id, record.id, JSON.stringify(event))]);
|
|
38
|
+
return result?.meta.changes === 1;
|
|
39
|
+
}
|
|
40
|
+
async appendAudit(event) {
|
|
41
|
+
await this.database.prepare("INSERT INTO vela_tenant_audit(id,tenant_id,event) VALUES(?,?,?)").bind(event.id, tenantId(event.tenantId), JSON.stringify(event)).run();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { D1TenantRegistryStore, decodeTenantRow, directoryOptions, tenantSqliteSchema };
|
|
46
|
+
|
|
47
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/d1/index.ts"],"sourcesContent":["import type { D1Binding } from '../storage-types';\nimport {\n parseTenant,\n tenantId,\n type TenantRecord,\n type TenantAuditEvent,\n type TenantRegistryStore,\n} from '../index';\n\nexport const tenantSqliteSchema = [\n \"CREATE TABLE IF NOT EXISTS vela_tenants (id TEXT PRIMARY KEY, name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended')), revision INTEGER NOT NULL, settings TEXT NOT NULL)\",\n 'CREATE TABLE IF NOT EXISTS vela_tenant_audit (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, event TEXT NOT NULL)',\n] as const;\n\nexport function decodeTenantRow(row: Record<string, unknown>): TenantRecord {\n if (typeof row.settings !== 'string') throw new TypeError('Invalid tenant settings column');\n return parseTenant({ ...row, settings: JSON.parse(row.settings) });\n}\nexport function directoryOptions(options: { after?: string; limit?: number } = {}): {\n after: string;\n limit: number;\n} {\n const limit = options.limit ?? 100;\n if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)\n throw new TypeError('Invalid directory limit');\n return { after: options.after ?? '', limit };\n}\n/** Admission uses the primary binding, never a replica session or KV snapshot. */\nexport class D1TenantRegistryStore implements TenantRegistryStore {\n constructor(private readonly database: D1Binding) {}\n async get(id: string): Promise<TenantRecord | null> {\n const row = await this.database\n .prepare('SELECT * FROM vela_tenants WHERE id = ?')\n .bind(tenantId(id))\n .first<Record<string, unknown>>();\n return row ? decodeTenantRow(row) : null;\n }\n async list(options?: { after?: string; limit?: number }): Promise<readonly TenantRecord[]> {\n const { after, limit } = directoryOptions(options);\n const result = await this.database\n .prepare('SELECT * FROM vela_tenants WHERE id > ? ORDER BY id LIMIT ?')\n .bind(after, limit)\n .all<Record<string, unknown>>();\n return result.results.map(decodeTenantRow);\n }\n async put(\n input: TenantRecord,\n expected: number | null,\n event: TenantAuditEvent,\n ): Promise<boolean> {\n const record = parseTenant(input);\n if (record.revision !== (expected ?? 0) + 1 || event.tenantId !== record.id)\n throw new TypeError('Invalid tenant transition');\n const mutation =\n expected === null\n ? this.database\n .prepare(\n 'INSERT INTO vela_tenants(id,name,status,revision,settings) VALUES(?,?,?,?,?) ON CONFLICT(id) DO NOTHING',\n )\n .bind(\n record.id,\n record.name,\n record.status,\n record.revision,\n JSON.stringify(record.settings),\n )\n : this.database\n .prepare(\n 'UPDATE vela_tenants SET name=?,status=?,revision=?,settings=? WHERE id=? AND revision=?',\n )\n .bind(\n record.name,\n record.status,\n record.revision,\n JSON.stringify(record.settings),\n record.id,\n expected,\n );\n const [result] = await this.database.batch([\n mutation,\n this.database\n .prepare(\n 'INSERT INTO vela_tenant_audit(id,tenant_id,event) SELECT ?,?,? WHERE changes() = 1',\n )\n .bind(event.id, record.id, JSON.stringify(event)),\n ]);\n return result?.meta.changes === 1;\n }\n async appendAudit(event: TenantAuditEvent): Promise<void> {\n await this.database\n .prepare('INSERT INTO vela_tenant_audit(id,tenant_id,event) VALUES(?,?,?)')\n .bind(event.id, tenantId(event.tenantId), JSON.stringify(event))\n .run();\n }\n}\n"],"mappings":";;AASA,MAAa,qBAAqB,CAChC,sMACA,kHACF;AAEA,SAAgB,gBAAgB,KAA4C;CAC1E,IAAI,OAAO,IAAI,aAAa,UAAU,MAAM,IAAI,UAAU,gCAAgC;CAC1F,OAAO,YAAY;EAAE,GAAG;EAAK,UAAU,KAAK,MAAM,IAAI,QAAQ;CAAE,CAAC;AACnE;AACA,SAAgB,iBAAiB,UAA8C,CAAC,GAG9E;CACA,MAAM,QAAQ,QAAQ,SAAS;CAC/B,IAAI,CAAC,OAAO,cAAc,KAAK,KAAK,QAAQ,KAAK,QAAQ,KACvD,MAAM,IAAI,UAAU,yBAAyB;CAC/C,OAAO;EAAE,OAAO,QAAQ,SAAS;EAAI;CAAM;AAC7C;;AAEA,IAAa,wBAAb,MAAkE;CACnC;CAA7B,YAAY,UAAsC;EAArB,KAAA,WAAA;CAAsB;CACnD,MAAM,IAAI,IAA0C;EAClD,MAAM,MAAM,MAAM,KAAK,SACpB,QAAQ,yCAAyC,CAAC,CAClD,KAAK,SAAS,EAAE,CAAC,CAAC,CAClB,MAA+B;EAClC,OAAO,MAAM,gBAAgB,GAAG,IAAI;CACtC;CACA,MAAM,KAAK,SAAgF;EACzF,MAAM,EAAE,OAAO,UAAU,iBAAiB,OAAO;EAKjD,QAAO,MAJc,KAAK,SACvB,QAAQ,6DAA6D,CAAC,CACtE,KAAK,OAAO,KAAK,CAAC,CAClB,IAA6B,EAAA,CAClB,QAAQ,IAAI,eAAe;CAC3C;CACA,MAAM,IACJ,OACA,UACA,OACkB;EAClB,MAAM,SAAS,YAAY,KAAK;EAChC,IAAI,OAAO,cAAc,YAAY,KAAK,KAAK,MAAM,aAAa,OAAO,IACvE,MAAM,IAAI,UAAU,2BAA2B;EACjD,MAAM,WACJ,aAAa,OACT,KAAK,SACF,QACC,yGACF,CAAC,CACA,KACC,OAAO,IACP,OAAO,MACP,OAAO,QACP,OAAO,UACP,KAAK,UAAU,OAAO,QAAQ,CAChC,IACF,KAAK,SACF,QACC,yFACF,CAAC,CACA,KACC,OAAO,MACP,OAAO,QACP,OAAO,UACP,KAAK,UAAU,OAAO,QAAQ,GAC9B,OAAO,IACP,QACF;EACR,MAAM,CAAC,UAAU,MAAM,KAAK,SAAS,MAAM,CACzC,UACA,KAAK,SACF,QACC,oFACF,CAAC,CACA,KAAK,MAAM,IAAI,OAAO,IAAI,KAAK,UAAU,KAAK,CAAC,CACpD,CAAC;EACD,OAAO,QAAQ,KAAK,YAAY;CAClC;CACA,MAAM,YAAY,OAAwC;EACxD,MAAM,KAAK,SACR,QAAQ,iEAAiE,CAAC,CAC1E,KAAK,MAAM,IAAI,SAAS,MAAM,QAAQ,GAAG,KAAK,UAAU,KAAK,CAAC,CAAC,CAC/D,IAAI;CACT;AACF"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { n as SqliteStorage } from "../storage-types-CoSKssiV.js";
|
|
2
|
+
import { TenantAuditEvent, TenantRecord, TenantRegistryStore } from "../index.js";
|
|
3
|
+
//#region src/durable-objects/index.d.ts
|
|
4
|
+
/** Construct inside a SQLite Durable Object; expose only authenticated application RPC. */
|
|
5
|
+
export declare class DurableObjectTenantRegistryStore implements TenantRegistryStore {
|
|
6
|
+
private readonly storage;
|
|
7
|
+
constructor(storage: SqliteStorage);
|
|
8
|
+
migrate(): void;
|
|
9
|
+
get(id: string): Promise<TenantRecord | null>;
|
|
10
|
+
list(options?: {
|
|
11
|
+
after?: string;
|
|
12
|
+
limit?: number;
|
|
13
|
+
}): Promise<readonly TenantRecord[]>;
|
|
14
|
+
put(input: TenantRecord, expected: number | null, event: TenantAuditEvent): Promise<boolean>;
|
|
15
|
+
appendAudit(event: TenantAuditEvent): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseTenant, tenantId } from "../index.js";
|
|
2
|
+
import { decodeTenantRow, directoryOptions, tenantSqliteSchema } from "../d1/index.js";
|
|
3
|
+
//#region src/durable-objects/index.ts
|
|
4
|
+
/** Construct inside a SQLite Durable Object; expose only authenticated application RPC. */
|
|
5
|
+
var DurableObjectTenantRegistryStore = class {
|
|
6
|
+
storage;
|
|
7
|
+
constructor(storage) {
|
|
8
|
+
this.storage = storage;
|
|
9
|
+
}
|
|
10
|
+
migrate() {
|
|
11
|
+
for (const statement of tenantSqliteSchema) this.storage.sql.exec(statement);
|
|
12
|
+
}
|
|
13
|
+
async get(id) {
|
|
14
|
+
const row = this.storage.sql.exec("SELECT * FROM vela_tenants WHERE id = ?", tenantId(id)).toArray()[0];
|
|
15
|
+
return row ? decodeTenantRow(row) : null;
|
|
16
|
+
}
|
|
17
|
+
async list(options) {
|
|
18
|
+
const { after, limit } = directoryOptions(options);
|
|
19
|
+
return this.storage.sql.exec("SELECT * FROM vela_tenants WHERE id > ? ORDER BY id LIMIT ?", after, limit).toArray().map(decodeTenantRow);
|
|
20
|
+
}
|
|
21
|
+
async put(input, expected, event) {
|
|
22
|
+
const row = parseTenant(input);
|
|
23
|
+
if (row.revision !== (expected ?? 0) + 1 || event.tenantId !== row.id) throw new TypeError("Invalid tenant transition");
|
|
24
|
+
return this.storage.transactionSync(() => {
|
|
25
|
+
if ((this.storage.sql.exec("SELECT revision FROM vela_tenants WHERE id=?", row.id).toArray()[0]?.revision ?? null) !== expected) return false;
|
|
26
|
+
this.storage.sql.exec("INSERT INTO vela_tenants(id,name,status,revision,settings) VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,status=excluded.status,revision=excluded.revision,settings=excluded.settings", row.id, row.name, row.status, row.revision, JSON.stringify(row.settings));
|
|
27
|
+
this.storage.sql.exec("INSERT INTO vela_tenant_audit(id,tenant_id,event) VALUES(?,?,?)", event.id, row.id, JSON.stringify(event));
|
|
28
|
+
return true;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async appendAudit(event) {
|
|
32
|
+
this.storage.sql.exec("INSERT INTO vela_tenant_audit(id,tenant_id,event) VALUES(?,?,?)", event.id, tenantId(event.tenantId), JSON.stringify(event));
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { DurableObjectTenantRegistryStore };
|
|
37
|
+
|
|
38
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/durable-objects/index.ts"],"sourcesContent":["import type { SqliteStorage } from '../storage-types';\nimport {\n parseTenant,\n tenantId,\n type TenantRecord,\n type TenantAuditEvent,\n type TenantRegistryStore,\n} from '../index';\nimport { tenantSqliteSchema, decodeTenantRow, directoryOptions } from '../d1/index';\n\n/** Construct inside a SQLite Durable Object; expose only authenticated application RPC. */\nexport class DurableObjectTenantRegistryStore implements TenantRegistryStore {\n constructor(private readonly storage: SqliteStorage) {}\n migrate(): void {\n for (const statement of tenantSqliteSchema) this.storage.sql.exec(statement);\n }\n async get(id: string): Promise<TenantRecord | null> {\n const row = this.storage.sql\n .exec('SELECT * FROM vela_tenants WHERE id = ?', tenantId(id))\n .toArray()[0];\n return row ? decodeTenantRow(row) : null;\n }\n async list(options?: { after?: string; limit?: number }): Promise<readonly TenantRecord[]> {\n const { after, limit } = directoryOptions(options);\n return this.storage.sql\n .exec('SELECT * FROM vela_tenants WHERE id > ? ORDER BY id LIMIT ?', after, limit)\n .toArray()\n .map(decodeTenantRow);\n }\n async put(\n input: TenantRecord,\n expected: number | null,\n event: TenantAuditEvent,\n ): Promise<boolean> {\n const row = parseTenant(input);\n if (row.revision !== (expected ?? 0) + 1 || event.tenantId !== row.id)\n throw new TypeError('Invalid tenant transition');\n return this.storage.transactionSync(() => {\n const current = this.storage.sql\n .exec('SELECT revision FROM vela_tenants WHERE id=?', row.id)\n .toArray()[0];\n if ((current?.revision ?? null) !== expected) return false;\n this.storage.sql.exec(\n 'INSERT INTO vela_tenants(id,name,status,revision,settings) VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,status=excluded.status,revision=excluded.revision,settings=excluded.settings',\n row.id,\n row.name,\n row.status,\n row.revision,\n JSON.stringify(row.settings),\n );\n this.storage.sql.exec(\n 'INSERT INTO vela_tenant_audit(id,tenant_id,event) VALUES(?,?,?)',\n event.id,\n row.id,\n JSON.stringify(event),\n );\n return true;\n });\n }\n async appendAudit(event: TenantAuditEvent): Promise<void> {\n this.storage.sql.exec(\n 'INSERT INTO vela_tenant_audit(id,tenant_id,event) VALUES(?,?,?)',\n event.id,\n tenantId(event.tenantId),\n JSON.stringify(event),\n );\n }\n}\n"],"mappings":";;;;AAWA,IAAa,mCAAb,MAA6E;CAC9C;CAA7B,YAAY,SAAyC;EAAxB,KAAA,UAAA;CAAyB;CACtD,UAAgB;EACd,KAAK,MAAM,aAAa,oBAAoB,KAAK,QAAQ,IAAI,KAAK,SAAS;CAC7E;CACA,MAAM,IAAI,IAA0C;EAClD,MAAM,MAAM,KAAK,QAAQ,IACtB,KAAK,2CAA2C,SAAS,EAAE,CAAC,CAAC,CAC7D,QAAQ,CAAC,CAAC;EACb,OAAO,MAAM,gBAAgB,GAAG,IAAI;CACtC;CACA,MAAM,KAAK,SAAgF;EACzF,MAAM,EAAE,OAAO,UAAU,iBAAiB,OAAO;EACjD,OAAO,KAAK,QAAQ,IACjB,KAAK,+DAA+D,OAAO,KAAK,CAAC,CACjF,QAAQ,CAAC,CACT,IAAI,eAAe;CACxB;CACA,MAAM,IACJ,OACA,UACA,OACkB;EAClB,MAAM,MAAM,YAAY,KAAK;EAC7B,IAAI,IAAI,cAAc,YAAY,KAAK,KAAK,MAAM,aAAa,IAAI,IACjE,MAAM,IAAI,UAAU,2BAA2B;EACjD,OAAO,KAAK,QAAQ,sBAAsB;GAIxC,KAHgB,KAAK,QAAQ,IAC1B,KAAK,gDAAgD,IAAI,EAAE,CAAC,CAC5D,QAAQ,CAAC,CAAC,EACD,EAAE,YAAY,UAAU,UAAU,OAAO;GACrD,KAAK,QAAQ,IAAI,KACf,8MACA,IAAI,IACJ,IAAI,MACJ,IAAI,QACJ,IAAI,UACJ,KAAK,UAAU,IAAI,QAAQ,CAC7B;GACA,KAAK,QAAQ,IAAI,KACf,mEACA,MAAM,IACN,IAAI,IACJ,KAAK,UAAU,KAAK,CACtB;GACA,OAAO;EACT,CAAC;CACH;CACA,MAAM,YAAY,OAAwC;EACxD,KAAK,QAAQ,IAAI,KACf,mEACA,MAAM,IACN,SAAS,MAAM,QAAQ,GACvB,KAAK,UAAU,KAAK,CACtB;CACF;AACF"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
export type TenantStatus = 'active' | 'suspended';
|
|
3
|
+
export interface TenantRecord {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly status: TenantStatus;
|
|
7
|
+
readonly revision: number;
|
|
8
|
+
readonly settings: Readonly<Record<string, unknown>>;
|
|
9
|
+
}
|
|
10
|
+
export interface TenantPrincipal {
|
|
11
|
+
readonly issuer: string;
|
|
12
|
+
readonly subject: string;
|
|
13
|
+
readonly principalType: 'user' | 'service';
|
|
14
|
+
readonly expiresAtMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface TenantLookup {
|
|
17
|
+
get(id: string): Promise<TenantRecord | null>;
|
|
18
|
+
}
|
|
19
|
+
export interface TenantAuditEvent {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly tenantId: string;
|
|
22
|
+
readonly action: 'create' | 'update' | 'administration';
|
|
23
|
+
readonly principal: TenantPrincipal;
|
|
24
|
+
readonly reason: string;
|
|
25
|
+
readonly at: number;
|
|
26
|
+
}
|
|
27
|
+
export interface TenantRegistryStore extends TenantLookup {
|
|
28
|
+
list(options?: {
|
|
29
|
+
after?: string;
|
|
30
|
+
limit?: number;
|
|
31
|
+
}): Promise<readonly TenantRecord[]>;
|
|
32
|
+
/** Compare-and-set and audit append MUST commit atomically. */
|
|
33
|
+
put(record: TenantRecord, expectedRevision: number | null, event: TenantAuditEvent): Promise<boolean>;
|
|
34
|
+
appendAudit(event: TenantAuditEvent): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export interface TenantSnapshot extends TenantRecord {
|
|
37
|
+
readonly authority: 'tenant' | 'targeted-administration';
|
|
38
|
+
readonly source: string;
|
|
39
|
+
readonly principal: TenantPrincipal;
|
|
40
|
+
}
|
|
41
|
+
export interface TenantContextReader {
|
|
42
|
+
current(): TenantSnapshot | undefined;
|
|
43
|
+
requireTenant(): TenantSnapshot;
|
|
44
|
+
requireTenantId(): string;
|
|
45
|
+
requireTenantTarget(): TenantSnapshot;
|
|
46
|
+
}
|
|
47
|
+
export declare class TenantError extends Error {
|
|
48
|
+
readonly code: 'INVALID_TENANT' | 'TENANT_REQUIRED' | 'TENANT_DENIED' | 'TENANT_CONFLICT' | 'TENANT_UNAVAILABLE';
|
|
49
|
+
constructor(code: 'INVALID_TENANT' | 'TENANT_REQUIRED' | 'TENANT_DENIED' | 'TENANT_CONFLICT' | 'TENANT_UNAVAILABLE', message?: "INVALID_TENANT" | "TENANT_CONFLICT" | "TENANT_DENIED" | "TENANT_REQUIRED" | "TENANT_UNAVAILABLE");
|
|
50
|
+
}
|
|
51
|
+
export declare function tenantId(value: unknown): string;
|
|
52
|
+
export declare function parseTenant(value: unknown): TenantRecord;
|
|
53
|
+
export interface TenantServiceOptions {
|
|
54
|
+
lookup: TenantLookup;
|
|
55
|
+
/** Membership/service authorization is mandatory, including background work. */
|
|
56
|
+
authorize(input: {
|
|
57
|
+
tenant: TenantRecord;
|
|
58
|
+
principal: TenantPrincipal;
|
|
59
|
+
source: string;
|
|
60
|
+
}): boolean | Promise<boolean>;
|
|
61
|
+
}
|
|
62
|
+
export interface TenantRunOptions {
|
|
63
|
+
tenantId: string;
|
|
64
|
+
principal: TenantPrincipal;
|
|
65
|
+
source?: string;
|
|
66
|
+
parent?: TenantContextReader;
|
|
67
|
+
}
|
|
68
|
+
export declare class TenantService {
|
|
69
|
+
private readonly options;
|
|
70
|
+
constructor(options: TenantServiceOptions);
|
|
71
|
+
admit(options: TenantRunOptions): Promise<TenantSnapshot>;
|
|
72
|
+
run<Result>(options: TenantRunOptions, work: (scope: TenantContextReader) => Result | Promise<Result>): Promise<Result>;
|
|
73
|
+
}
|
|
74
|
+
export interface TenantAdministrationOptions {
|
|
75
|
+
store: TenantRegistryStore;
|
|
76
|
+
authorize(input: {
|
|
77
|
+
principal: TenantPrincipal;
|
|
78
|
+
tenantId: string;
|
|
79
|
+
action: TenantAuditEvent['action'];
|
|
80
|
+
reason: string;
|
|
81
|
+
}): boolean | Promise<boolean>;
|
|
82
|
+
}
|
|
83
|
+
export declare class TenantRegistry {
|
|
84
|
+
#private;
|
|
85
|
+
private readonly options;
|
|
86
|
+
constructor(options: TenantAdministrationOptions);
|
|
87
|
+
save(input: Omit<TenantRecord, 'revision'>, expectedRevision: number | null, principal: TenantPrincipal, reason: string): Promise<TenantRecord>;
|
|
88
|
+
runForTenant<Result>(id: string, principal: TenantPrincipal, reason: string, work: (scope: TenantContextReader) => Result | Promise<Result>): Promise<Result>;
|
|
89
|
+
}
|
|
90
|
+
export declare class MemoryTenantRegistryStore implements TenantRegistryStore {
|
|
91
|
+
#private;
|
|
92
|
+
constructor(records?: readonly TenantRecord[]);
|
|
93
|
+
get(id: string): Promise<TenantRecord | null>;
|
|
94
|
+
list({ after, limit }?: {
|
|
95
|
+
after?: string;
|
|
96
|
+
limit?: number;
|
|
97
|
+
}): Promise<readonly TenantRecord[]>;
|
|
98
|
+
put(record: TenantRecord, expected: number | null, event: TenantAuditEvent): Promise<boolean>;
|
|
99
|
+
appendAudit(event: TenantAuditEvent): Promise<void>;
|
|
100
|
+
audit(): readonly TenantAuditEvent[];
|
|
101
|
+
}
|
|
102
|
+
export type TenantSelector = (request: Request) => string | undefined;
|
|
103
|
+
/** Adapter for @velajs/crud/multi-tenant. The resolver must verify authentication. */
|
|
104
|
+
export declare function tenantAdmission(service: TenantService, principal: (request: Request) => TenantPrincipal | undefined | Promise<TenantPrincipal | undefined>): {
|
|
105
|
+
admit(selector: string, request: Request): Promise<TenantSnapshot>;
|
|
106
|
+
};
|
|
107
|
+
export declare const headerTenant: (name?: string) => TenantSelector;
|
|
108
|
+
export declare const queryTenant: (name?: string) => TenantSelector;
|
|
109
|
+
export declare const hostTenant: (baseDomain: string) => TenantSelector;
|
|
110
|
+
//#endregion
|
|
111
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
var TenantError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message = code) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "TenantError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
function tenantId(value) {
|
|
11
|
+
if (typeof value !== "string" || !value || value.trim() !== value || new TextEncoder().encode(value).length > 256 || /[\u0000-\u001f\u007f]/.test(value)) throw new TenantError("INVALID_TENANT");
|
|
12
|
+
return value.normalize("NFC");
|
|
13
|
+
}
|
|
14
|
+
function jsonSnapshot(value, depth = 0) {
|
|
15
|
+
if (depth > 32) throw new TypeError("Tenant settings are too deep");
|
|
16
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
17
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
18
|
+
if (Array.isArray(value)) return Object.freeze(value.map((v) => jsonSnapshot(v, depth + 1)));
|
|
19
|
+
if (typeof value !== "object" || value === null || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new TypeError("Tenant settings must be JSON");
|
|
20
|
+
return Object.freeze(Object.fromEntries(Object.keys(value).map((key) => {
|
|
21
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
22
|
+
if (!descriptor || !("value" in descriptor)) throw new TypeError("Tenant settings cannot contain accessors");
|
|
23
|
+
return [key, jsonSnapshot(descriptor.value, depth + 1)];
|
|
24
|
+
})));
|
|
25
|
+
}
|
|
26
|
+
function parseTenant(value) {
|
|
27
|
+
if (!value || typeof value !== "object" || !("id" in value) || !("name" in value) || !("status" in value) || !("revision" in value) || !("settings" in value) || typeof value.name !== "string" || value.status !== "active" && value.status !== "suspended" || typeof value.revision !== "number" || !Number.isSafeInteger(value.revision) || value.revision < 1) throw new TypeError("Invalid tenant record");
|
|
28
|
+
const settings = jsonSnapshot(value.settings);
|
|
29
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new TypeError("Tenant settings must be a record");
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
id: tenantId(value.id),
|
|
32
|
+
name: value.name,
|
|
33
|
+
status: value.status,
|
|
34
|
+
revision: value.revision,
|
|
35
|
+
settings: Object.freeze(Object.fromEntries(Object.entries(settings)))
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function principalSnapshot(principal) {
|
|
39
|
+
const issuer = tenantId(principal.issuer), subject = tenantId(principal.subject);
|
|
40
|
+
if (principal.principalType !== "user" && principal.principalType !== "service") throw new TenantError("TENANT_DENIED");
|
|
41
|
+
if (principal.expiresAtMs !== void 0 && (!Number.isSafeInteger(principal.expiresAtMs) || principal.expiresAtMs <= Date.now())) throw new TenantError("TENANT_DENIED");
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
issuer,
|
|
44
|
+
subject,
|
|
45
|
+
principalType: principal.principalType,
|
|
46
|
+
...principal.expiresAtMs === void 0 ? {} : { expiresAtMs: principal.expiresAtMs }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Scope objects are application-owned capabilities, never ambient singleton state. */
|
|
50
|
+
var OperationTenantScope = class {
|
|
51
|
+
snapshot;
|
|
52
|
+
#active = true;
|
|
53
|
+
constructor(snapshot) {
|
|
54
|
+
this.snapshot = snapshot;
|
|
55
|
+
}
|
|
56
|
+
close() {
|
|
57
|
+
this.#active = false;
|
|
58
|
+
}
|
|
59
|
+
current() {
|
|
60
|
+
return this.#active && (this.snapshot.principal.expiresAtMs === void 0 || this.snapshot.principal.expiresAtMs > Date.now()) ? this.snapshot : void 0;
|
|
61
|
+
}
|
|
62
|
+
requireTenantTarget() {
|
|
63
|
+
const current = this.current();
|
|
64
|
+
if (!current) throw new TenantError("TENANT_REQUIRED");
|
|
65
|
+
return current;
|
|
66
|
+
}
|
|
67
|
+
requireTenant() {
|
|
68
|
+
const current = this.requireTenantTarget();
|
|
69
|
+
if (current.authority !== "tenant") throw new TenantError("TENANT_DENIED");
|
|
70
|
+
return current;
|
|
71
|
+
}
|
|
72
|
+
requireTenantId() {
|
|
73
|
+
return this.requireTenant().id;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
var TenantService = class {
|
|
77
|
+
options;
|
|
78
|
+
constructor(options) {
|
|
79
|
+
this.options = options;
|
|
80
|
+
}
|
|
81
|
+
async admit(options) {
|
|
82
|
+
const id = tenantId(options.tenantId), principal = principalSnapshot(options.principal);
|
|
83
|
+
const parent = options.parent?.requireTenant();
|
|
84
|
+
if (parent && (parent.id !== id || parent.principal.issuer !== principal.issuer || parent.principal.subject !== principal.subject || parent.principal.principalType !== principal.principalType)) throw new TenantError("TENANT_CONFLICT");
|
|
85
|
+
const found = await this.options.lookup.get(id);
|
|
86
|
+
if (!found) throw new TenantError("TENANT_DENIED");
|
|
87
|
+
const tenant = parseTenant(found);
|
|
88
|
+
if (tenant.id !== id || tenant.status !== "active") throw new TenantError("TENANT_DENIED");
|
|
89
|
+
const source = options.source ?? "programmatic";
|
|
90
|
+
if (await this.options.authorize({
|
|
91
|
+
tenant,
|
|
92
|
+
principal,
|
|
93
|
+
source
|
|
94
|
+
}) !== true) throw new TenantError("TENANT_DENIED");
|
|
95
|
+
const latest = await this.options.lookup.get(id);
|
|
96
|
+
if (!latest || parseTenant(latest).revision !== tenant.revision || latest.status !== "active") throw new TenantError("TENANT_DENIED");
|
|
97
|
+
principalSnapshot(principal);
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
...tenant,
|
|
100
|
+
principal,
|
|
101
|
+
source,
|
|
102
|
+
authority: "tenant"
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async run(options, work) {
|
|
106
|
+
const scope = new OperationTenantScope(await this.admit(options));
|
|
107
|
+
try {
|
|
108
|
+
return await work(scope);
|
|
109
|
+
} finally {
|
|
110
|
+
scope.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
var TenantRegistry = class {
|
|
115
|
+
options;
|
|
116
|
+
constructor(options) {
|
|
117
|
+
this.options = options;
|
|
118
|
+
}
|
|
119
|
+
async #event(id, action, principal, reason) {
|
|
120
|
+
principal = principalSnapshot(principal);
|
|
121
|
+
if (!reason.trim() || reason.length > 512 || /[\u0000-\u001f\u007f]/.test(reason)) throw new TenantError("TENANT_DENIED");
|
|
122
|
+
if (await this.options.authorize({
|
|
123
|
+
principal,
|
|
124
|
+
tenantId: id,
|
|
125
|
+
action,
|
|
126
|
+
reason
|
|
127
|
+
}) !== true) throw new TenantError("TENANT_DENIED");
|
|
128
|
+
principalSnapshot(principal);
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
id: crypto.randomUUID(),
|
|
131
|
+
tenantId: id,
|
|
132
|
+
action,
|
|
133
|
+
principal,
|
|
134
|
+
reason,
|
|
135
|
+
at: Date.now()
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async save(input, expectedRevision, principal, reason) {
|
|
139
|
+
if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)) throw new TypeError("Invalid expected tenant revision");
|
|
140
|
+
const record = parseTenant({
|
|
141
|
+
...input,
|
|
142
|
+
revision: (expectedRevision ?? 0) + 1
|
|
143
|
+
});
|
|
144
|
+
const event = await this.#event(record.id, expectedRevision === null ? "create" : "update", principal, reason);
|
|
145
|
+
if (!await this.options.store.put(record, expectedRevision, event)) throw new TenantError("TENANT_CONFLICT");
|
|
146
|
+
return record;
|
|
147
|
+
}
|
|
148
|
+
async runForTenant(id, principal, reason, work) {
|
|
149
|
+
id = tenantId(id);
|
|
150
|
+
const record = await this.options.store.get(id);
|
|
151
|
+
if (!record || parseTenant(record).id !== id) throw new TenantError("TENANT_DENIED");
|
|
152
|
+
const event = await this.#event(id, "administration", principal, reason);
|
|
153
|
+
await this.options.store.appendAudit(event);
|
|
154
|
+
const scope = new OperationTenantScope(Object.freeze({
|
|
155
|
+
...parseTenant(record),
|
|
156
|
+
principal: event.principal,
|
|
157
|
+
source: "administration",
|
|
158
|
+
authority: "targeted-administration"
|
|
159
|
+
}));
|
|
160
|
+
try {
|
|
161
|
+
return await work(scope);
|
|
162
|
+
} finally {
|
|
163
|
+
scope.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
var MemoryTenantRegistryStore = class {
|
|
168
|
+
#records = /* @__PURE__ */ new Map();
|
|
169
|
+
#events = [];
|
|
170
|
+
constructor(records = []) {
|
|
171
|
+
for (const row of records) {
|
|
172
|
+
const parsed = parseTenant(row);
|
|
173
|
+
this.#records.set(parsed.id, parsed);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async get(id) {
|
|
177
|
+
return this.#records.get(tenantId(id)) ?? null;
|
|
178
|
+
}
|
|
179
|
+
async list({ after = "", limit = 100 } = {}) {
|
|
180
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e3) throw new TypeError("Invalid directory limit");
|
|
181
|
+
return [...this.#records.values()].filter((r) => r.id > after).sort((a, b) => a.id < b.id ? -1 : 1).slice(0, limit);
|
|
182
|
+
}
|
|
183
|
+
async put(record, expected, event) {
|
|
184
|
+
const parsed = parseTenant(record);
|
|
185
|
+
if (event.tenantId !== parsed.id) throw new TypeError("Conflicting audit target");
|
|
186
|
+
if ((this.#records.get(parsed.id)?.revision ?? null) !== expected || parsed.revision !== (expected ?? 0) + 1) return false;
|
|
187
|
+
const audit = Object.freeze(structuredClone(event));
|
|
188
|
+
this.#records.set(parsed.id, parsed);
|
|
189
|
+
this.#events.push(audit);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
async appendAudit(event) {
|
|
193
|
+
this.#events.push(Object.freeze(structuredClone(event)));
|
|
194
|
+
}
|
|
195
|
+
audit() {
|
|
196
|
+
return structuredClone(this.#events);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
/** Adapter for @velajs/crud/multi-tenant. The resolver must verify authentication. */
|
|
200
|
+
function tenantAdmission(service, principal) {
|
|
201
|
+
return { async admit(selector, request) {
|
|
202
|
+
const identity = await principal(request);
|
|
203
|
+
if (!identity) throw new TenantError("TENANT_DENIED");
|
|
204
|
+
return service.admit({
|
|
205
|
+
tenantId: selector,
|
|
206
|
+
principal: identity,
|
|
207
|
+
source: "http"
|
|
208
|
+
});
|
|
209
|
+
} };
|
|
210
|
+
}
|
|
211
|
+
const headerTenant = (name = "x-tenant-id") => (request) => request.headers.get(name) ?? void 0;
|
|
212
|
+
const queryTenant = (name = "tenantId") => (request) => {
|
|
213
|
+
const values = new URL(request.url).searchParams.getAll(name);
|
|
214
|
+
if (values.length > 1) throw new TenantError("INVALID_TENANT");
|
|
215
|
+
return values[0];
|
|
216
|
+
};
|
|
217
|
+
const hostTenant = (baseDomain) => (request) => {
|
|
218
|
+
const host = new URL(request.url).hostname.toLowerCase();
|
|
219
|
+
const suffix = `.${baseDomain.toLowerCase()}`;
|
|
220
|
+
return host.endsWith(suffix) ? host.slice(0, -suffix.length) : void 0;
|
|
221
|
+
};
|
|
222
|
+
//#endregion
|
|
223
|
+
export { MemoryTenantRegistryStore, TenantError, TenantRegistry, TenantService, headerTenant, hostTenant, parseTenant, queryTenant, tenantAdmission, tenantId };
|
|
224
|
+
|
|
225
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["export type TenantStatus = 'active' | 'suspended';\nexport interface TenantRecord {\n readonly id: string;\n readonly name: string;\n readonly status: TenantStatus;\n readonly revision: number;\n readonly settings: Readonly<Record<string, unknown>>;\n}\nexport interface TenantPrincipal {\n readonly issuer: string;\n readonly subject: string;\n readonly principalType: 'user' | 'service';\n readonly expiresAtMs?: number;\n}\nexport interface TenantLookup {\n get(id: string): Promise<TenantRecord | null>;\n}\nexport interface TenantAuditEvent {\n readonly id: string;\n readonly tenantId: string;\n readonly action: 'create' | 'update' | 'administration';\n readonly principal: TenantPrincipal;\n readonly reason: string;\n readonly at: number;\n}\nexport interface TenantRegistryStore extends TenantLookup {\n list(options?: { after?: string; limit?: number }): Promise<readonly TenantRecord[]>;\n /** Compare-and-set and audit append MUST commit atomically. */\n put(\n record: TenantRecord,\n expectedRevision: number | null,\n event: TenantAuditEvent,\n ): Promise<boolean>;\n appendAudit(event: TenantAuditEvent): Promise<void>;\n}\nexport interface TenantSnapshot extends TenantRecord {\n readonly authority: 'tenant' | 'targeted-administration';\n readonly source: string;\n readonly principal: TenantPrincipal;\n}\nexport interface TenantContextReader {\n current(): TenantSnapshot | undefined;\n requireTenant(): TenantSnapshot;\n requireTenantId(): string;\n requireTenantTarget(): TenantSnapshot;\n}\nexport class TenantError extends Error {\n constructor(\n readonly code:\n | 'INVALID_TENANT'\n | 'TENANT_REQUIRED'\n | 'TENANT_DENIED'\n | 'TENANT_CONFLICT'\n | 'TENANT_UNAVAILABLE',\n message = code,\n ) {\n super(message);\n this.name = 'TenantError';\n }\n}\nexport function tenantId(value: unknown): string {\n if (\n typeof value !== 'string' ||\n !value ||\n value.trim() !== value ||\n new TextEncoder().encode(value).length > 256 ||\n /[\\u0000-\\u001f\\u007f]/.test(value)\n ) {\n throw new TenantError('INVALID_TENANT');\n }\n return value.normalize('NFC');\n}\nfunction jsonSnapshot(value: unknown, depth = 0): unknown {\n if (depth > 32) throw new TypeError('Tenant settings are too deep');\n if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;\n if (typeof value === 'number' && Number.isFinite(value)) return value;\n if (Array.isArray(value)) return Object.freeze(value.map((v) => jsonSnapshot(v, depth + 1)));\n if (\n typeof value !== 'object' ||\n value === null ||\n ![Object.prototype, null].includes(Object.getPrototypeOf(value))\n )\n throw new TypeError('Tenant settings must be JSON');\n return Object.freeze(\n Object.fromEntries(\n Object.keys(value).map((key) => {\n const descriptor = Object.getOwnPropertyDescriptor(value, key);\n if (!descriptor || !('value' in descriptor))\n throw new TypeError('Tenant settings cannot contain accessors');\n return [key, jsonSnapshot(descriptor.value, depth + 1)];\n }),\n ),\n );\n}\nexport function parseTenant(value: unknown): TenantRecord {\n if (\n !value ||\n typeof value !== 'object' ||\n !('id' in value) ||\n !('name' in value) ||\n !('status' in value) ||\n !('revision' in value) ||\n !('settings' in value) ||\n typeof value.name !== 'string' ||\n (value.status !== 'active' && value.status !== 'suspended') ||\n typeof value.revision !== 'number' ||\n !Number.isSafeInteger(value.revision) ||\n value.revision < 1\n ) {\n throw new TypeError('Invalid tenant record');\n }\n const settings = jsonSnapshot(value.settings);\n if (!settings || typeof settings !== 'object' || Array.isArray(settings))\n throw new TypeError('Tenant settings must be a record');\n return Object.freeze({\n id: tenantId(value.id),\n name: value.name,\n status: value.status,\n revision: value.revision,\n settings: Object.freeze(Object.fromEntries(Object.entries(settings))),\n });\n}\nfunction principalSnapshot(principal: TenantPrincipal): TenantPrincipal {\n const issuer = tenantId(principal.issuer),\n subject = tenantId(principal.subject);\n if (principal.principalType !== 'user' && principal.principalType !== 'service')\n throw new TenantError('TENANT_DENIED');\n if (\n principal.expiresAtMs !== undefined &&\n (!Number.isSafeInteger(principal.expiresAtMs) || principal.expiresAtMs <= Date.now())\n )\n throw new TenantError('TENANT_DENIED');\n return Object.freeze({\n issuer,\n subject,\n principalType: principal.principalType,\n ...(principal.expiresAtMs === undefined ? {} : { expiresAtMs: principal.expiresAtMs }),\n });\n}\n\n/** Scope objects are application-owned capabilities, never ambient singleton state. */\nclass OperationTenantScope implements TenantContextReader {\n #active = true;\n constructor(private readonly snapshot: TenantSnapshot) {}\n close(): void {\n this.#active = false;\n }\n current(): TenantSnapshot | undefined {\n return this.#active &&\n (this.snapshot.principal.expiresAtMs === undefined ||\n this.snapshot.principal.expiresAtMs > Date.now())\n ? this.snapshot\n : undefined;\n }\n requireTenantTarget(): TenantSnapshot {\n const current = this.current();\n if (!current) throw new TenantError('TENANT_REQUIRED');\n return current;\n }\n requireTenant(): TenantSnapshot {\n const current = this.requireTenantTarget();\n if (current.authority !== 'tenant') throw new TenantError('TENANT_DENIED');\n return current;\n }\n requireTenantId(): string {\n return this.requireTenant().id;\n }\n}\nexport interface TenantServiceOptions {\n lookup: TenantLookup;\n /** Membership/service authorization is mandatory, including background work. */\n authorize(input: {\n tenant: TenantRecord;\n principal: TenantPrincipal;\n source: string;\n }): boolean | Promise<boolean>;\n}\nexport interface TenantRunOptions {\n tenantId: string;\n principal: TenantPrincipal;\n source?: string;\n parent?: TenantContextReader;\n}\nexport class TenantService {\n constructor(private readonly options: TenantServiceOptions) {}\n async admit(options: TenantRunOptions): Promise<TenantSnapshot> {\n const id = tenantId(options.tenantId),\n principal = principalSnapshot(options.principal);\n const parent = options.parent?.requireTenant();\n if (\n parent &&\n (parent.id !== id ||\n parent.principal.issuer !== principal.issuer ||\n parent.principal.subject !== principal.subject ||\n parent.principal.principalType !== principal.principalType)\n )\n throw new TenantError('TENANT_CONFLICT');\n // No positive cache: a new admission always checks the authoritative store.\n const found = await this.options.lookup.get(id);\n if (!found) throw new TenantError('TENANT_DENIED');\n const tenant = parseTenant(found);\n if (tenant.id !== id || tenant.status !== 'active') throw new TenantError('TENANT_DENIED');\n const source = options.source ?? 'programmatic';\n if ((await this.options.authorize({ tenant, principal, source })) !== true)\n throw new TenantError('TENANT_DENIED');\n // Reject an authority revision that changed while membership was being checked.\n const latest = await this.options.lookup.get(id);\n if (!latest || parseTenant(latest).revision !== tenant.revision || latest.status !== 'active')\n throw new TenantError('TENANT_DENIED');\n principalSnapshot(principal);\n return Object.freeze({ ...tenant, principal, source, authority: 'tenant' });\n }\n async run<Result>(\n options: TenantRunOptions,\n work: (scope: TenantContextReader) => Result | Promise<Result>,\n ): Promise<Result> {\n const scope = new OperationTenantScope(await this.admit(options));\n try {\n return await work(scope);\n } finally {\n scope.close();\n }\n }\n}\nexport interface TenantAdministrationOptions {\n store: TenantRegistryStore;\n authorize(input: {\n principal: TenantPrincipal;\n tenantId: string;\n action: TenantAuditEvent['action'];\n reason: string;\n }): boolean | Promise<boolean>;\n}\nexport class TenantRegistry {\n constructor(private readonly options: TenantAdministrationOptions) {}\n async #event(\n id: string,\n action: TenantAuditEvent['action'],\n principal: TenantPrincipal,\n reason: string,\n ): Promise<TenantAuditEvent> {\n principal = principalSnapshot(principal);\n if (!reason.trim() || reason.length > 512 || /[\\u0000-\\u001f\\u007f]/.test(reason))\n throw new TenantError('TENANT_DENIED');\n if ((await this.options.authorize({ principal, tenantId: id, action, reason })) !== true)\n throw new TenantError('TENANT_DENIED');\n principalSnapshot(principal);\n return Object.freeze({\n id: crypto.randomUUID(),\n tenantId: id,\n action,\n principal,\n reason,\n at: Date.now(),\n });\n }\n async save(\n input: Omit<TenantRecord, 'revision'>,\n expectedRevision: number | null,\n principal: TenantPrincipal,\n reason: string,\n ): Promise<TenantRecord> {\n if (\n expectedRevision !== null &&\n (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)\n )\n throw new TypeError('Invalid expected tenant revision');\n const record = parseTenant({ ...input, revision: (expectedRevision ?? 0) + 1 });\n const event = await this.#event(\n record.id,\n expectedRevision === null ? 'create' : 'update',\n principal,\n reason,\n );\n if (!(await this.options.store.put(record, expectedRevision, event)))\n throw new TenantError('TENANT_CONFLICT');\n return record;\n }\n async runForTenant<Result>(\n id: string,\n principal: TenantPrincipal,\n reason: string,\n work: (scope: TenantContextReader) => Result | Promise<Result>,\n ): Promise<Result> {\n id = tenantId(id);\n const record = await this.options.store.get(id);\n if (!record || parseTenant(record).id !== id) throw new TenantError('TENANT_DENIED');\n const event = await this.#event(id, 'administration', principal, reason);\n await this.options.store.appendAudit(event);\n const scope = new OperationTenantScope(\n Object.freeze({\n ...parseTenant(record),\n principal: event.principal,\n source: 'administration',\n authority: 'targeted-administration',\n }),\n );\n try {\n return await work(scope);\n } finally {\n scope.close();\n }\n }\n}\n\nexport class MemoryTenantRegistryStore implements TenantRegistryStore {\n readonly #records = new Map<string, TenantRecord>();\n readonly #events: TenantAuditEvent[] = [];\n constructor(records: readonly TenantRecord[] = []) {\n for (const row of records) {\n const parsed = parseTenant(row);\n this.#records.set(parsed.id, parsed);\n }\n }\n async get(id: string): Promise<TenantRecord | null> {\n return this.#records.get(tenantId(id)) ?? null;\n }\n async list({ after = '', limit = 100 }: { after?: string; limit?: number } = {}): Promise<\n readonly TenantRecord[]\n > {\n if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)\n throw new TypeError('Invalid directory limit');\n return [...this.#records.values()]\n .filter((r) => r.id > after)\n .sort((a, b) => (a.id < b.id ? -1 : 1))\n .slice(0, limit);\n }\n async put(\n record: TenantRecord,\n expected: number | null,\n event: TenantAuditEvent,\n ): Promise<boolean> {\n const parsed = parseTenant(record);\n if (event.tenantId !== parsed.id) throw new TypeError('Conflicting audit target');\n if (\n (this.#records.get(parsed.id)?.revision ?? null) !== expected ||\n parsed.revision !== (expected ?? 0) + 1\n )\n return false;\n const audit = Object.freeze(structuredClone(event));\n this.#records.set(parsed.id, parsed);\n this.#events.push(audit);\n return true;\n }\n async appendAudit(event: TenantAuditEvent): Promise<void> {\n this.#events.push(Object.freeze(structuredClone(event)));\n }\n audit(): readonly TenantAuditEvent[] {\n return structuredClone(this.#events);\n }\n}\n\nexport type TenantSelector = (request: Request) => string | undefined;\n/** Adapter for @velajs/crud/multi-tenant. The resolver must verify authentication. */\nexport function tenantAdmission(\n service: TenantService,\n principal: (\n request: Request,\n ) => TenantPrincipal | undefined | Promise<TenantPrincipal | undefined>,\n): {\n admit(selector: string, request: Request): Promise<TenantSnapshot>;\n} {\n return {\n async admit(selector, request) {\n const identity = await principal(request);\n if (!identity) throw new TenantError('TENANT_DENIED');\n return service.admit({ tenantId: selector, principal: identity, source: 'http' });\n },\n };\n}\nexport const headerTenant =\n (name = 'x-tenant-id'): TenantSelector =>\n (request) =>\n request.headers.get(name) ?? undefined;\nexport const queryTenant =\n (name = 'tenantId'): TenantSelector =>\n (request) => {\n const values = new URL(request.url).searchParams.getAll(name);\n if (values.length > 1) throw new TenantError('INVALID_TENANT');\n return values[0];\n };\nexport const hostTenant =\n (baseDomain: string): TenantSelector =>\n (request) => {\n const host = new URL(request.url).hostname.toLowerCase();\n const suffix = `.${baseDomain.toLowerCase()}`;\n return host.endsWith(suffix) ? host.slice(0, -suffix.length) : undefined;\n };\n"],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { TenantAuditEvent, TenantRecord, TenantRegistryStore } from "../index.js";
|
|
2
|
+
//#region src/postgres/index.d.ts
|
|
3
|
+
/** Supply a cache-disabled Hyperdrive client. The driver owns its connections. */
|
|
4
|
+
export interface TenantPostgresClient {
|
|
5
|
+
query(sql: string, values: readonly unknown[]): Promise<{
|
|
6
|
+
rows: Record<string, unknown>[];
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
export declare const tenantPostgresSchema: readonly ["CREATE TABLE IF NOT EXISTS vela_tenants (id text PRIMARY KEY, name text NOT NULL, status text NOT NULL CHECK(status IN ('active','suspended')), revision integer NOT NULL, settings jsonb NOT NULL)", 'CREATE TABLE IF NOT EXISTS vela_tenant_audit (id text PRIMARY KEY, tenant_id text NOT NULL, event jsonb NOT NULL)'];
|
|
10
|
+
export declare class PostgresTenantRegistryStore implements TenantRegistryStore {
|
|
11
|
+
private readonly client;
|
|
12
|
+
constructor(client: TenantPostgresClient);
|
|
13
|
+
get(id: string): Promise<TenantRecord | null>;
|
|
14
|
+
list(options?: {
|
|
15
|
+
after?: string;
|
|
16
|
+
limit?: number;
|
|
17
|
+
}): Promise<readonly TenantRecord[]>;
|
|
18
|
+
put(input: TenantRecord, expected: number | null, event: TenantAuditEvent): Promise<boolean>;
|
|
19
|
+
appendAudit(event: TenantAuditEvent): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface TenantPostgresTransaction extends TenantPostgresClient {}
|
|
22
|
+
export interface TenantPostgresExecutor {
|
|
23
|
+
transaction<T>(work: (transaction: TenantPostgresTransaction) => Promise<T>): Promise<T>;
|
|
24
|
+
}
|
|
25
|
+
/** Every read/write runs on the same transaction-local tenant connection. */
|
|
26
|
+
export declare function withTenantRls<T>(executor: TenantPostgresExecutor, tenant: {
|
|
27
|
+
requireTenantId(): string;
|
|
28
|
+
}, work: (transaction: TenantPostgresTransaction) => Promise<T>): Promise<T>;
|
|
29
|
+
export declare function tenantRlsStatements(table: string, column?: string): readonly string[];
|
|
30
|
+
//#endregion
|
|
31
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { parseTenant, tenantId } from "../index.js";
|
|
2
|
+
import { directoryOptions } from "../d1/index.js";
|
|
3
|
+
//#region src/postgres/index.ts
|
|
4
|
+
const tenantPostgresSchema = ["CREATE TABLE IF NOT EXISTS vela_tenants (id text PRIMARY KEY, name text NOT NULL, status text NOT NULL CHECK(status IN ('active','suspended')), revision integer NOT NULL, settings jsonb NOT NULL)", "CREATE TABLE IF NOT EXISTS vela_tenant_audit (id text PRIMARY KEY, tenant_id text NOT NULL, event jsonb NOT NULL)"];
|
|
5
|
+
var PostgresTenantRegistryStore = class {
|
|
6
|
+
client;
|
|
7
|
+
constructor(client) {
|
|
8
|
+
this.client = client;
|
|
9
|
+
}
|
|
10
|
+
async get(id) {
|
|
11
|
+
const { rows } = await this.client.query("SELECT * FROM vela_tenants WHERE id = $1", [tenantId(id)]);
|
|
12
|
+
return rows[0] ? parseTenant(rows[0]) : null;
|
|
13
|
+
}
|
|
14
|
+
async list(options) {
|
|
15
|
+
const { after, limit } = directoryOptions(options);
|
|
16
|
+
const { rows } = await this.client.query("SELECT * FROM vela_tenants WHERE id > $1 ORDER BY id LIMIT $2", [after, limit]);
|
|
17
|
+
return rows.map(parseTenant);
|
|
18
|
+
}
|
|
19
|
+
async put(input, expected, event) {
|
|
20
|
+
const row = parseTenant(input);
|
|
21
|
+
if (row.revision !== (expected ?? 0) + 1 || event.tenantId !== row.id) throw new TypeError("Invalid tenant transition");
|
|
22
|
+
const values = [
|
|
23
|
+
row.id,
|
|
24
|
+
row.name,
|
|
25
|
+
row.status,
|
|
26
|
+
row.revision,
|
|
27
|
+
JSON.stringify(row.settings),
|
|
28
|
+
expected,
|
|
29
|
+
event.id,
|
|
30
|
+
JSON.stringify(event)
|
|
31
|
+
];
|
|
32
|
+
const mutation = expected === null ? "INSERT INTO vela_tenants(id,name,status,revision,settings) VALUES($1,$2,$3,$4,$5::jsonb) ON CONFLICT(id) DO NOTHING RETURNING id" : "UPDATE vela_tenants SET name=$2,status=$3,revision=$4,settings=$5::jsonb WHERE id=$1 AND revision=$6 RETURNING id";
|
|
33
|
+
const { rows } = await this.client.query(`WITH input AS (SELECT $6::integer), changed AS (${mutation}), audit AS (INSERT INTO vela_tenant_audit(id,tenant_id,event) SELECT $7,id,$8::jsonb FROM changed RETURNING id) SELECT id FROM audit`, values);
|
|
34
|
+
return rows.length === 1;
|
|
35
|
+
}
|
|
36
|
+
async appendAudit(event) {
|
|
37
|
+
await this.client.query("INSERT INTO vela_tenant_audit(id,tenant_id,event) VALUES($1,$2,$3::jsonb)", [
|
|
38
|
+
event.id,
|
|
39
|
+
tenantId(event.tenantId),
|
|
40
|
+
JSON.stringify(event)
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/** Every read/write runs on the same transaction-local tenant connection. */
|
|
45
|
+
function withTenantRls(executor, tenant, work) {
|
|
46
|
+
const id = tenantId(tenant.requireTenantId());
|
|
47
|
+
return executor.transaction(async (tx) => {
|
|
48
|
+
await tx.query("SELECT set_config('vela.tenant_id', $1, true)", [id]);
|
|
49
|
+
return work(tx);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function tenantRlsStatements(table, column = "tenant_id") {
|
|
53
|
+
const quote = (s) => {
|
|
54
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) throw new TypeError("Invalid SQL identifier");
|
|
55
|
+
return `"${s}"`;
|
|
56
|
+
};
|
|
57
|
+
const t = quote(table);
|
|
58
|
+
const condition = `${quote(column)}::text = nullif(current_setting('vela.tenant_id', true), '')`;
|
|
59
|
+
return [
|
|
60
|
+
`ALTER TABLE ${t} ENABLE ROW LEVEL SECURITY`,
|
|
61
|
+
`ALTER TABLE ${t} FORCE ROW LEVEL SECURITY`,
|
|
62
|
+
`CREATE POLICY vela_tenant_permit ON ${t} AS PERMISSIVE FOR ALL USING (${condition}) WITH CHECK (${condition})`,
|
|
63
|
+
`CREATE POLICY vela_tenant_restrict ON ${t} AS RESTRICTIVE FOR ALL USING (${condition}) WITH CHECK (${condition})`
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { PostgresTenantRegistryStore, tenantPostgresSchema, tenantRlsStatements, withTenantRls };
|
|
68
|
+
|
|
69
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/postgres/index.ts"],"sourcesContent":["import {\n parseTenant,\n tenantId,\n type TenantRecord,\n type TenantAuditEvent,\n type TenantRegistryStore,\n} from '../index';\nimport { directoryOptions } from '../d1/index';\n\n/** Supply a cache-disabled Hyperdrive client. The driver owns its connections. */\nexport interface TenantPostgresClient {\n query(sql: string, values: readonly unknown[]): Promise<{ rows: Record<string, unknown>[] }>;\n}\nexport const tenantPostgresSchema = [\n \"CREATE TABLE IF NOT EXISTS vela_tenants (id text PRIMARY KEY, name text NOT NULL, status text NOT NULL CHECK(status IN ('active','suspended')), revision integer NOT NULL, settings jsonb NOT NULL)\",\n 'CREATE TABLE IF NOT EXISTS vela_tenant_audit (id text PRIMARY KEY, tenant_id text NOT NULL, event jsonb NOT NULL)',\n] as const;\n\nexport class PostgresTenantRegistryStore implements TenantRegistryStore {\n constructor(private readonly client: TenantPostgresClient) {}\n async get(id: string): Promise<TenantRecord | null> {\n const { rows } = await this.client.query('SELECT * FROM vela_tenants WHERE id = $1', [\n tenantId(id),\n ]);\n return rows[0] ? parseTenant(rows[0]) : null;\n }\n async list(options?: { after?: string; limit?: number }): Promise<readonly TenantRecord[]> {\n const { after, limit } = directoryOptions(options);\n const { rows } = await this.client.query(\n 'SELECT * FROM vela_tenants WHERE id > $1 ORDER BY id LIMIT $2',\n [after, limit],\n );\n return rows.map(parseTenant);\n }\n async put(\n input: TenantRecord,\n expected: number | null,\n event: TenantAuditEvent,\n ): Promise<boolean> {\n const row = parseTenant(input);\n if (row.revision !== (expected ?? 0) + 1 || event.tenantId !== row.id)\n throw new TypeError('Invalid tenant transition');\n const values = [\n row.id,\n row.name,\n row.status,\n row.revision,\n JSON.stringify(row.settings),\n expected,\n event.id,\n JSON.stringify(event),\n ];\n const mutation =\n expected === null\n ? 'INSERT INTO vela_tenants(id,name,status,revision,settings) VALUES($1,$2,$3,$4,$5::jsonb) ON CONFLICT(id) DO NOTHING RETURNING id'\n : 'UPDATE vela_tenants SET name=$2,status=$3,revision=$4,settings=$5::jsonb WHERE id=$1 AND revision=$6 RETURNING id';\n // $6 must be typed even on the insert branch for PostgreSQL's parameter inference.\n const { rows } = await this.client.query(\n `WITH input AS (SELECT $6::integer), changed AS (${mutation}), audit AS (INSERT INTO vela_tenant_audit(id,tenant_id,event) SELECT $7,id,$8::jsonb FROM changed RETURNING id) SELECT id FROM audit`,\n values,\n );\n return rows.length === 1;\n }\n async appendAudit(event: TenantAuditEvent): Promise<void> {\n await this.client.query(\n 'INSERT INTO vela_tenant_audit(id,tenant_id,event) VALUES($1,$2,$3::jsonb)',\n [event.id, tenantId(event.tenantId), JSON.stringify(event)],\n );\n }\n}\n\nexport interface TenantPostgresTransaction extends TenantPostgresClient {}\nexport interface TenantPostgresExecutor {\n transaction<T>(work: (transaction: TenantPostgresTransaction) => Promise<T>): Promise<T>;\n}\n/** Every read/write runs on the same transaction-local tenant connection. */\nexport function withTenantRls<T>(\n executor: TenantPostgresExecutor,\n tenant: { requireTenantId(): string },\n work: (transaction: TenantPostgresTransaction) => Promise<T>,\n): Promise<T> {\n const id = tenantId(tenant.requireTenantId());\n return executor.transaction(async (tx) => {\n await tx.query(\"SELECT set_config('vela.tenant_id', $1, true)\", [id]);\n return work(tx);\n });\n}\nexport function tenantRlsStatements(table: string, column = 'tenant_id'): readonly string[] {\n const quote = (s: string) => {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) throw new TypeError('Invalid SQL identifier');\n return `\"${s}\"`;\n };\n const t = quote(table),\n c = quote(column);\n const condition = `${c}::text = nullif(current_setting('vela.tenant_id', true), '')`;\n return [\n `ALTER TABLE ${t} ENABLE ROW LEVEL SECURITY`,\n `ALTER TABLE ${t} FORCE ROW LEVEL SECURITY`,\n `CREATE POLICY vela_tenant_permit ON ${t} AS PERMISSIVE FOR ALL USING (${condition}) WITH CHECK (${condition})`,\n `CREATE POLICY vela_tenant_restrict ON ${t} AS RESTRICTIVE FOR ALL USING (${condition}) WITH CHECK (${condition})`,\n ];\n}\n"],"mappings":";;;AAaA,MAAa,uBAAuB,CAClC,uMACA,mHACF;AAEA,IAAa,8BAAb,MAAwE;CACzC;CAA7B,YAAY,QAA+C;EAA9B,KAAA,SAAA;CAA+B;CAC5D,MAAM,IAAI,IAA0C;EAClD,MAAM,EAAE,SAAS,MAAM,KAAK,OAAO,MAAM,4CAA4C,CACnF,SAAS,EAAE,CACb,CAAC;EACD,OAAO,KAAK,KAAK,YAAY,KAAK,EAAE,IAAI;CAC1C;CACA,MAAM,KAAK,SAAgF;EACzF,MAAM,EAAE,OAAO,UAAU,iBAAiB,OAAO;EACjD,MAAM,EAAE,SAAS,MAAM,KAAK,OAAO,MACjC,iEACA,CAAC,OAAO,KAAK,CACf;EACA,OAAO,KAAK,IAAI,WAAW;CAC7B;CACA,MAAM,IACJ,OACA,UACA,OACkB;EAClB,MAAM,MAAM,YAAY,KAAK;EAC7B,IAAI,IAAI,cAAc,YAAY,KAAK,KAAK,MAAM,aAAa,IAAI,IACjE,MAAM,IAAI,UAAU,2BAA2B;EACjD,MAAM,SAAS;GACb,IAAI;GACJ,IAAI;GACJ,IAAI;GACJ,IAAI;GACJ,KAAK,UAAU,IAAI,QAAQ;GAC3B;GACA,MAAM;GACN,KAAK,UAAU,KAAK;EACtB;EACA,MAAM,WACJ,aAAa,OACT,qIACA;EAEN,MAAM,EAAE,SAAS,MAAM,KAAK,OAAO,MACjC,mDAAmD,SAAS,wIAC5D,MACF;EACA,OAAO,KAAK,WAAW;CACzB;CACA,MAAM,YAAY,OAAwC;EACxD,MAAM,KAAK,OAAO,MAChB,6EACA;GAAC,MAAM;GAAI,SAAS,MAAM,QAAQ;GAAG,KAAK,UAAU,KAAK;EAAC,CAC5D;CACF;AACF;;AAOA,SAAgB,cACd,UACA,QACA,MACY;CACZ,MAAM,KAAK,SAAS,OAAO,gBAAgB,CAAC;CAC5C,OAAO,SAAS,YAAY,OAAO,OAAO;EACxC,MAAM,GAAG,MAAM,iDAAiD,CAAC,EAAE,CAAC;EACpE,OAAO,KAAK,EAAE;CAChB,CAAC;AACH;AACA,SAAgB,oBAAoB,OAAe,SAAS,aAAgC;CAC1F,MAAM,SAAS,MAAc;EAC3B,IAAI,CAAC,2BAA2B,KAAK,CAAC,GAAG,MAAM,IAAI,UAAU,wBAAwB;EACrF,OAAO,IAAI,EAAE;CACf;CACA,MAAM,IAAI,MAAM,KAAK;CAErB,MAAM,YAAY,GADZ,MAAM,MACS,EAAE;CACvB,OAAO;EACL,eAAe,EAAE;EACjB,eAAe,EAAE;EACjB,uCAAuC,EAAE,gCAAgC,UAAU,gBAAgB,UAAU;EAC7G,yCAAyC,EAAE,iCAAiC,UAAU,gBAAgB,UAAU;CAClH;AACF"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/storage-types.d.ts
|
|
2
|
+
/** Minimal structural bindings; portable declarations do not inject Worker globals. */
|
|
3
|
+
interface D1Statement {
|
|
4
|
+
bind(...values: unknown[]): D1Statement;
|
|
5
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
6
|
+
all<T = Record<string, unknown>>(): Promise<{
|
|
7
|
+
results: T[];
|
|
8
|
+
}>;
|
|
9
|
+
run(): Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
interface D1Binding {
|
|
12
|
+
prepare(sql: string): D1Statement;
|
|
13
|
+
batch(statements: D1Statement[]): Promise<Array<{
|
|
14
|
+
meta: {
|
|
15
|
+
changes: number;
|
|
16
|
+
};
|
|
17
|
+
}>>;
|
|
18
|
+
}
|
|
19
|
+
interface SqliteStorage {
|
|
20
|
+
sql: {
|
|
21
|
+
exec(sql: string, ...bindings: (string | number | null)[]): {
|
|
22
|
+
toArray(): Record<string, unknown>[];
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
transactionSync<T>(work: () => T): T;
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { SqliteStorage as n, D1Binding as t };
|
|
29
|
+
//# sourceMappingURL=storage-types-CoSKssiV.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { TenantContextReader, TenantRunOptions, TenantSelector, TenantService, TenantServiceOptions } from "../index.js";
|
|
2
|
+
import { CanActivate, Container, ExecutionContext, InjectionToken } from "@velajs/vela";
|
|
3
|
+
//#region src/vela/index.d.ts
|
|
4
|
+
export declare const TENANT_SERVICE: InjectionToken<TenantService>;
|
|
5
|
+
export declare const TENANT_CONTEXT_READER: InjectionToken<TenantContextReader>;
|
|
6
|
+
export declare const TenantRequired: () => ClassDecorator & MethodDecorator;
|
|
7
|
+
export declare const TenantOptional: () => ClassDecorator & MethodDecorator;
|
|
8
|
+
export declare const TenantIgnored: () => ClassDecorator & MethodDecorator;
|
|
9
|
+
export interface TenantModuleOptions extends TenantServiceOptions {
|
|
10
|
+
selector?: TenantSelector;
|
|
11
|
+
/** Non-HTTP transports supply verified credentials through their transport adapter. */
|
|
12
|
+
resolve?: (context: ExecutionContext) => TenantRunOptions | undefined | Promise<TenantRunOptions | undefined>;
|
|
13
|
+
}
|
|
14
|
+
declare const ConfigurableModuleClass: import("@velajs/vela").ConfigurableModuleClassType<TenantModuleOptions, "forRoot", "create", {
|
|
15
|
+
isGlobal?: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
export declare class TenantModule extends ConfigurableModuleClass {}
|
|
18
|
+
export declare class TenantGuard implements CanActivate {
|
|
19
|
+
#private;
|
|
20
|
+
canActivate(context: ExecutionContext): Promise<boolean>;
|
|
21
|
+
}
|
|
22
|
+
export declare const CurrentTenant: (data?: undefined, ...pipes: import("@velajs/vela").PipeType[]) => ParameterDecorator;
|
|
23
|
+
/** One queue message, scheduled tenant, or WebSocket operation gets a fresh child scope.
|
|
24
|
+
* The principal must come from a trusted transport adapter; payload tenant IDs are selectors. */
|
|
25
|
+
export declare function runInTenantScope<T>(container: Container, options: TenantRunOptions, work: (container: Container, tenant: TenantContextReader) => T | Promise<T>, moduleId?: string): Promise<T>;
|
|
26
|
+
//#endregion
|
|
27
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { TenantError, TenantService, headerTenant } from "../index.js";
|
|
2
|
+
import { BadRequestException, ForbiddenException, InjectionToken, REQUEST_CONTEXT, Reflector, Scope, createParamDecorator, defineModule, defineProvider, getTrustedRequestIdentity, runInEntrypointScope, setTrustedRequestTenant } from "@velajs/vela";
|
|
3
|
+
//#region src/vela/index.ts
|
|
4
|
+
const TENANT_SERVICE = new InjectionToken("vela.tenant.service");
|
|
5
|
+
const TENANT_CONTEXT_READER = new InjectionToken("vela.tenant.reader");
|
|
6
|
+
var TenantScopeState = class {
|
|
7
|
+
#snapshot;
|
|
8
|
+
#active = true;
|
|
9
|
+
#identity;
|
|
10
|
+
publish(snapshot, identity) {
|
|
11
|
+
if (!this.#active || this.#snapshot && this.#snapshot.id !== snapshot.id) throw new TenantError("TENANT_CONFLICT");
|
|
12
|
+
this.#snapshot = snapshot;
|
|
13
|
+
this.#identity = identity;
|
|
14
|
+
}
|
|
15
|
+
current() {
|
|
16
|
+
const value = this.#snapshot;
|
|
17
|
+
return this.#active && value && (value.principal.expiresAtMs === void 0 || value.principal.expiresAtMs > Date.now()) && (!this.#identity || this.#identity()) ? value : void 0;
|
|
18
|
+
}
|
|
19
|
+
requireTenant() {
|
|
20
|
+
const value = this.current();
|
|
21
|
+
if (!value || value.authority !== "tenant") throw new TenantError("TENANT_REQUIRED");
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
requireTenantId() {
|
|
25
|
+
return this.requireTenant().id;
|
|
26
|
+
}
|
|
27
|
+
requireTenantTarget() {
|
|
28
|
+
return this.requireTenant();
|
|
29
|
+
}
|
|
30
|
+
dispose() {
|
|
31
|
+
this.#active = false;
|
|
32
|
+
this.#snapshot = void 0;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
function scopeState(container, moduleId) {
|
|
36
|
+
const readers = container.resolveAll(TENANT_CONTEXT_READER, moduleId);
|
|
37
|
+
if (readers.length !== 1 || !(readers[0] instanceof TenantScopeState)) throw new TenantError("TENANT_CONFLICT");
|
|
38
|
+
return readers[0];
|
|
39
|
+
}
|
|
40
|
+
const requirement = Reflector.createDecorator({ key: "vela.tenant.requirement" });
|
|
41
|
+
const TenantRequired = () => requirement("required");
|
|
42
|
+
const TenantOptional = () => requirement("optional");
|
|
43
|
+
const TenantIgnored = () => requirement("ignored");
|
|
44
|
+
const OPTIONS = new InjectionToken("vela.tenant.options");
|
|
45
|
+
const { ConfigurableModuleClass } = defineModule({
|
|
46
|
+
name: "Tenant",
|
|
47
|
+
optionsToken: OPTIONS,
|
|
48
|
+
setup: ({ OPTIONS }) => ({
|
|
49
|
+
providers: [defineProvider(TENANT_SERVICE, {
|
|
50
|
+
inject: [OPTIONS],
|
|
51
|
+
useFactory: (options) => new TenantService(options)
|
|
52
|
+
}), defineProvider(TENANT_CONTEXT_READER, {
|
|
53
|
+
scope: Scope.REQUEST,
|
|
54
|
+
inject: [],
|
|
55
|
+
useFactory: () => new TenantScopeState()
|
|
56
|
+
})],
|
|
57
|
+
exports: [
|
|
58
|
+
TENANT_SERVICE,
|
|
59
|
+
TENANT_CONTEXT_READER,
|
|
60
|
+
OPTIONS
|
|
61
|
+
]
|
|
62
|
+
})
|
|
63
|
+
});
|
|
64
|
+
var TenantModule = class extends ConfigurableModuleClass {};
|
|
65
|
+
var TenantGuard = class {
|
|
66
|
+
#reflector = new Reflector();
|
|
67
|
+
async canActivate(context) {
|
|
68
|
+
if (this.#reflector.getAllAndOverride(requirement, context) === "ignored") return true;
|
|
69
|
+
const container = context.getContainer();
|
|
70
|
+
const owners = container?.getOwnerModuleIds(context.getClass()) ?? [];
|
|
71
|
+
const moduleId = context.getModuleId() ?? (owners.length === 1 ? owners[0] : void 0);
|
|
72
|
+
if (!container || !moduleId) throw new ForbiddenException("Tenant scope unavailable");
|
|
73
|
+
const candidates = container.resolveAll(TENANT_SERVICE, moduleId);
|
|
74
|
+
const options = container.resolveAll(OPTIONS, moduleId);
|
|
75
|
+
if (candidates.length !== 1 || options.length !== 1) throw new ForbiddenException("Tenant configuration is ambiguous");
|
|
76
|
+
const service = candidates[0], config = options[0];
|
|
77
|
+
const requestContext = context.getType() === "http" ? container.resolve(REQUEST_CONTEXT) : void 0;
|
|
78
|
+
const request = requestContext?.request;
|
|
79
|
+
const identity = request ? getTrustedRequestIdentity(request) : void 0;
|
|
80
|
+
let input = await config.resolve?.(context);
|
|
81
|
+
if (!input && request) {
|
|
82
|
+
const id = (config.selector ?? headerTenant())(request) ?? identity?.tenantId;
|
|
83
|
+
if (id !== void 0 && !identity) throw new ForbiddenException("Tenant selector requires authenticated identity");
|
|
84
|
+
if (id !== void 0 && identity) input = {
|
|
85
|
+
tenantId: id,
|
|
86
|
+
principal: {
|
|
87
|
+
...identity.principal,
|
|
88
|
+
...identity.expiresAtMs === void 0 ? {} : { expiresAtMs: identity.expiresAtMs }
|
|
89
|
+
},
|
|
90
|
+
source: "http"
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!input) {
|
|
94
|
+
if (this.#reflector.getAllAndOverride(requirement, context) === "optional") return true;
|
|
95
|
+
throw new BadRequestException("Tenant and authenticated identity are required");
|
|
96
|
+
}
|
|
97
|
+
if (identity?.tenantId && identity.tenantId !== input.tenantId) throw new ForbiddenException("Conflicting tenant identity");
|
|
98
|
+
if (identity && (identity.principal.issuer !== input.principal.issuer || identity.principal.subject !== input.principal.subject || identity.principal.principalType !== input.principal.principalType)) throw new ForbiddenException("Conflicting tenant principal");
|
|
99
|
+
let tenant;
|
|
100
|
+
try {
|
|
101
|
+
tenant = await service.admit(input);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof TenantError) throw new ForbiddenException("Tenant access denied");
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
if (request) {
|
|
107
|
+
if (!identity || getTrustedRequestIdentity(request) !== identity) throw new ForbiddenException("Identity changed during tenant admission");
|
|
108
|
+
setTrustedRequestTenant(request, identity, tenant.id);
|
|
109
|
+
}
|
|
110
|
+
const admittedIdentity = request ? getTrustedRequestIdentity(request) : void 0;
|
|
111
|
+
scopeState(container, moduleId).publish(tenant, request ? () => getTrustedRequestIdentity(request) === admittedIdentity : void 0);
|
|
112
|
+
requestContext?.hono.set("tenantId", tenant.id);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const CurrentTenant = createParamDecorator((_data, context) => context.getContainer()?.resolve(TENANT_CONTEXT_READER, context.getModuleId()).requireTenant());
|
|
117
|
+
/** One queue message, scheduled tenant, or WebSocket operation gets a fresh child scope.
|
|
118
|
+
* The principal must come from a trusted transport adapter; payload tenant IDs are selectors. */
|
|
119
|
+
function runInTenantScope(container, options, work, moduleId) {
|
|
120
|
+
return runInEntrypointScope(container, async (scope) => {
|
|
121
|
+
const services = scope.resolveAll(TENANT_SERVICE, moduleId);
|
|
122
|
+
if (services.length !== 1) throw new TenantError("TENANT_CONFLICT");
|
|
123
|
+
return services[0].run(options, async (tenant) => {
|
|
124
|
+
const state = scopeState(scope, moduleId);
|
|
125
|
+
state.publish(tenant.requireTenant());
|
|
126
|
+
return work(scope, state);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
export { CurrentTenant, TENANT_CONTEXT_READER, TENANT_SERVICE, TenantGuard, TenantIgnored, TenantModule, TenantOptional, TenantRequired, runInTenantScope };
|
|
132
|
+
|
|
133
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/vela/index.ts"],"sourcesContent":["import {\n InjectionToken,\n REQUEST_CONTEXT,\n runInEntrypointScope,\n type Container,\n Scope,\n defineModule,\n defineProvider,\n createParamDecorator,\n Reflector,\n ForbiddenException,\n BadRequestException,\n getTrustedRequestIdentity,\n setTrustedRequestTenant,\n type CanActivate,\n type ExecutionContext,\n} from '@velajs/vela';\nimport {\n TenantService,\n TenantError,\n headerTenant,\n type TenantSnapshot,\n type TenantContextReader,\n type TenantServiceOptions,\n type TenantSelector,\n type TenantRunOptions,\n} from '../index';\n\nexport const TENANT_SERVICE = new InjectionToken<TenantService>('vela.tenant.service');\nexport const TENANT_CONTEXT_READER = new InjectionToken<TenantContextReader>('vela.tenant.reader');\nclass TenantScopeState implements TenantContextReader {\n #snapshot: TenantSnapshot | undefined;\n #active = true;\n #identity: (() => boolean) | undefined;\n publish(snapshot: TenantSnapshot, identity?: () => boolean): void {\n if (!this.#active || (this.#snapshot && this.#snapshot.id !== snapshot.id))\n throw new TenantError('TENANT_CONFLICT');\n this.#snapshot = snapshot;\n this.#identity = identity;\n }\n current(): TenantSnapshot | undefined {\n const value = this.#snapshot;\n return this.#active &&\n value &&\n (value.principal.expiresAtMs === undefined || value.principal.expiresAtMs > Date.now()) &&\n (!this.#identity || this.#identity())\n ? value\n : undefined;\n }\n requireTenant(): TenantSnapshot {\n const value = this.current();\n if (!value || value.authority !== 'tenant') throw new TenantError('TENANT_REQUIRED');\n return value;\n }\n requireTenantId(): string {\n return this.requireTenant().id;\n }\n requireTenantTarget(): TenantSnapshot {\n return this.requireTenant();\n }\n dispose(): void {\n this.#active = false;\n this.#snapshot = undefined;\n }\n}\nfunction scopeState(container: Container, moduleId?: string): TenantScopeState {\n const readers = container.resolveAll(TENANT_CONTEXT_READER, moduleId);\n if (readers.length !== 1 || !(readers[0] instanceof TenantScopeState))\n throw new TenantError('TENANT_CONFLICT');\n return readers[0];\n}\nconst requirement = Reflector.createDecorator<'required' | 'optional' | 'ignored'>({\n key: 'vela.tenant.requirement',\n});\nexport const TenantRequired = () => requirement('required');\nexport const TenantOptional = () => requirement('optional');\nexport const TenantIgnored = () => requirement('ignored');\nexport interface TenantModuleOptions extends TenantServiceOptions {\n selector?: TenantSelector;\n /** Non-HTTP transports supply verified credentials through their transport adapter. */\n resolve?: (\n context: ExecutionContext,\n ) => TenantRunOptions | undefined | Promise<TenantRunOptions | undefined>;\n}\nconst OPTIONS = new InjectionToken<TenantModuleOptions>('vela.tenant.options');\nconst { ConfigurableModuleClass } = defineModule<TenantModuleOptions>({\n name: 'Tenant',\n optionsToken: OPTIONS,\n setup: ({ OPTIONS }) => ({\n providers: [\n defineProvider(TENANT_SERVICE, {\n inject: [OPTIONS],\n useFactory: (options) => new TenantService(options),\n }),\n defineProvider(TENANT_CONTEXT_READER, {\n scope: Scope.REQUEST,\n inject: [],\n useFactory: () => new TenantScopeState(),\n }),\n ],\n exports: [TENANT_SERVICE, TENANT_CONTEXT_READER, OPTIONS],\n }),\n});\nexport class TenantModule extends ConfigurableModuleClass {}\nexport class TenantGuard implements CanActivate {\n readonly #reflector = new Reflector();\n async canActivate(context: ExecutionContext): Promise<boolean> {\n if (this.#reflector.getAllAndOverride(requirement, context) === 'ignored') return true;\n const container = context.getContainer();\n const owners = container?.getOwnerModuleIds(context.getClass()) ?? [];\n const moduleId = context.getModuleId() ?? (owners.length === 1 ? owners[0] : undefined);\n if (!container || !moduleId) throw new ForbiddenException('Tenant scope unavailable');\n const candidates = container.resolveAll(TENANT_SERVICE, moduleId);\n const options = container.resolveAll(OPTIONS, moduleId);\n if (candidates.length !== 1 || options.length !== 1)\n throw new ForbiddenException('Tenant configuration is ambiguous');\n const service = candidates[0]!,\n config = options[0]!;\n const requestContext =\n context.getType() === 'http' ? container.resolve(REQUEST_CONTEXT) : undefined;\n const request = requestContext?.request;\n const identity = request ? getTrustedRequestIdentity(request) : undefined;\n let input = await config.resolve?.(context);\n if (!input && request) {\n const id = (config.selector ?? headerTenant())(request) ?? identity?.tenantId;\n if (id !== undefined && !identity)\n throw new ForbiddenException('Tenant selector requires authenticated identity');\n if (id !== undefined && identity)\n input = {\n tenantId: id,\n principal: {\n ...identity.principal,\n ...(identity.expiresAtMs === undefined ? {} : { expiresAtMs: identity.expiresAtMs }),\n },\n source: 'http',\n };\n }\n if (!input) {\n if (this.#reflector.getAllAndOverride(requirement, context) === 'optional') return true;\n throw new BadRequestException('Tenant and authenticated identity are required');\n }\n if (identity?.tenantId && identity.tenantId !== input.tenantId)\n throw new ForbiddenException('Conflicting tenant identity');\n if (\n identity &&\n (identity.principal.issuer !== input.principal.issuer ||\n identity.principal.subject !== input.principal.subject ||\n identity.principal.principalType !== input.principal.principalType)\n )\n throw new ForbiddenException('Conflicting tenant principal');\n let tenant: TenantSnapshot;\n try {\n tenant = await service.admit(input);\n } catch (error) {\n if (error instanceof TenantError) throw new ForbiddenException('Tenant access denied');\n throw error;\n }\n if (request) {\n if (!identity || getTrustedRequestIdentity(request) !== identity)\n throw new ForbiddenException('Identity changed during tenant admission');\n setTrustedRequestTenant(request, identity, tenant.id);\n }\n const admittedIdentity = request ? getTrustedRequestIdentity(request) : undefined;\n scopeState(container, moduleId).publish(\n tenant,\n request ? () => getTrustedRequestIdentity(request) === admittedIdentity : undefined,\n );\n // Compatibility bridge for independently usable Hono CRUD resources.\n requestContext?.hono.set('tenantId' as never, tenant.id as never);\n return true;\n }\n}\nexport const CurrentTenant = createParamDecorator((_data: undefined, context: ExecutionContext) =>\n context.getContainer()?.resolve(TENANT_CONTEXT_READER, context.getModuleId()).requireTenant(),\n);\n\n/** One queue message, scheduled tenant, or WebSocket operation gets a fresh child scope.\n * The principal must come from a trusted transport adapter; payload tenant IDs are selectors. */\nexport function runInTenantScope<T>(\n container: Container,\n options: TenantRunOptions,\n work: (container: Container, tenant: TenantContextReader) => T | Promise<T>,\n moduleId?: string,\n): Promise<T> {\n return runInEntrypointScope(container, async (scope) => {\n const services = scope.resolveAll(TENANT_SERVICE, moduleId);\n if (services.length !== 1) throw new TenantError('TENANT_CONFLICT');\n return services[0]!.run(options, async (tenant) => {\n const state = scopeState(scope, moduleId);\n state.publish(tenant.requireTenant());\n return work(scope, state);\n });\n });\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@velajs/tenant",
|
|
3
|
+
"version": "1.24.1",
|
|
4
|
+
"description": "Portable tenant for Vela and Cloudflare Workers",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cloudflare-workers",
|
|
7
|
+
"tenant",
|
|
8
|
+
"vela"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/velajs/vela/tree/main/packages/tenant#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/velajs/vela/issues"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "ksh",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/velajs/vela.git",
|
|
19
|
+
"directory": "packages/tenant"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./vela": {
|
|
37
|
+
"types": "./dist/vela/index.d.ts",
|
|
38
|
+
"import": "./dist/vela/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./d1": {
|
|
41
|
+
"types": "./dist/d1/index.d.ts",
|
|
42
|
+
"import": "./dist/d1/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./postgres": {
|
|
45
|
+
"types": "./dist/postgres/index.d.ts",
|
|
46
|
+
"import": "./dist/postgres/index.js"
|
|
47
|
+
},
|
|
48
|
+
"./durable-objects": {
|
|
49
|
+
"types": "./dist/durable-objects/index.d.ts",
|
|
50
|
+
"import": "./dist/durable-objects/index.js"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
58
|
+
"@changesets/cli": "3.0.1",
|
|
59
|
+
"@cloudflare/workers-types": "5.20260920.1",
|
|
60
|
+
"oxfmt": "0.58.0",
|
|
61
|
+
"oxlint": "1.73.0",
|
|
62
|
+
"publint": "0.3.21",
|
|
63
|
+
"tsdown": "0.23.0",
|
|
64
|
+
"typescript": "7.0.2",
|
|
65
|
+
"vitest": "4.1.10",
|
|
66
|
+
"@velajs/vela": "1.25.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"@velajs/vela": "^1.25.0"
|
|
70
|
+
},
|
|
71
|
+
"peerDependenciesMeta": {
|
|
72
|
+
"@velajs/vela": {
|
|
73
|
+
"optional": true
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"engines": {
|
|
77
|
+
"node": ">=24"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"build": "tsdown",
|
|
81
|
+
"test": "vitest run",
|
|
82
|
+
"typecheck": "tsc --noEmit -p tsconfig.test.json",
|
|
83
|
+
"lint": "oxlint .",
|
|
84
|
+
"format": "oxfmt .",
|
|
85
|
+
"format:check": "oxfmt --check .",
|
|
86
|
+
"publint": "publint",
|
|
87
|
+
"attw": "attw --pack . --profile esm-only",
|
|
88
|
+
"verify": "pnpm lint && pnpm format:check && pnpm build && pnpm typecheck && pnpm test && pnpm publint && pnpm attw"
|
|
89
|
+
}
|
|
90
|
+
}
|