@govcore/server 0.1.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 +31 -0
- package/LICENSE +21 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/package.json +29 -0
- package/src/index.ts +101 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @govcore/server
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f3bce48: `createTenantActions` now accepts a `createRbac()` instance for its `rbac` gate without a cast. `CreateTenantActionsConfig.rbac.hasPermission` is declared with method syntax so its bivariant parameters accept a gate typed with the app's role/permission _literals_ (what `@govcore/rbac` returns), while the active role is checked as a `string`. Removes a consumer footgun (#45).
|
|
8
|
+
|
|
9
|
+
## 0.1.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 8db62da: Phase 4 (part 1) — the action seam and the accessibility floor.
|
|
14
|
+
|
|
15
|
+
`@govcore/server`: `createTenantActions` → a typed `tenantAction` wrapper. It
|
|
16
|
+
resolves the actor's active context (never trusting caller input), applies an
|
|
17
|
+
optional permission gate, opens a transaction and sets the transaction-local
|
|
18
|
+
`app.current_org` GUC so the schema's RLS policies bind to every query, and hands
|
|
19
|
+
the handler an `audit` fn pre-bound to the actor + org.
|
|
20
|
+
|
|
21
|
+
`@govcore/theme`: a WCAG-AA base token set (`base.css`, light/dark, visible focus
|
|
22
|
+
ring), a Tailwind `baseTheme` preset, and `defineTheme` — which only allows
|
|
23
|
+
overriding allowlisted brand vars and sanitizes values to prevent inline-`<style>`
|
|
24
|
+
breakout (#769).
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- Updated dependencies
|
|
29
|
+
- Updated dependencies [f2f3743]
|
|
30
|
+
- @govcore/audit@0.1.0
|
|
31
|
+
- @govcore/schema@0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Allred
|
|
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/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type GovcoreDb } from '@govcore/schema';
|
|
2
|
+
import { type AuditEvent } from '@govcore/audit';
|
|
3
|
+
/** The trusted, server-resolved identity of the current actor. */
|
|
4
|
+
export interface ActiveContext {
|
|
5
|
+
userId: string;
|
|
6
|
+
organizationId: string;
|
|
7
|
+
role: string;
|
|
8
|
+
instanceRole?: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** What a handler receives: the active context plus a pre-bound audit writer. */
|
|
11
|
+
export interface TenantActionContext extends ActiveContext {
|
|
12
|
+
/** Write an audit row in the *same* transaction, pre-filled with actor + org. */
|
|
13
|
+
audit: (event: AuditEvent) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface CreateTenantActionsConfig {
|
|
16
|
+
db: GovcoreDb;
|
|
17
|
+
/** Resolve the current actor's active context — typically from the auth() session. */
|
|
18
|
+
getActiveContext: () => Promise<ActiveContext | null>;
|
|
19
|
+
/**
|
|
20
|
+
* Optional permission gate — pass an `@govcore/rbac` instance directly. Method
|
|
21
|
+
* syntax (not an arrow property) so its bivariant parameters accept a
|
|
22
|
+
* `createRbac<Role, Permission>()` whose `hasPermission` is typed with the
|
|
23
|
+
* app's role/permission *literals*, while the active role here is a `string`.
|
|
24
|
+
*/
|
|
25
|
+
rbac?: {
|
|
26
|
+
hasPermission(role: string, permission: string): boolean;
|
|
27
|
+
};
|
|
28
|
+
/** Called when there is no active context. Default: throws Error('Unauthorized'). */
|
|
29
|
+
onUnauthorized?: () => never;
|
|
30
|
+
/** Called when the permission check fails. Default: throws Error('Forbidden'). */
|
|
31
|
+
onForbidden?: () => never;
|
|
32
|
+
}
|
|
33
|
+
export type TenantActionHandler<I, O> = (args: {
|
|
34
|
+
ctx: TenantActionContext;
|
|
35
|
+
db: GovcoreDb;
|
|
36
|
+
}, input: I) => Promise<O>;
|
|
37
|
+
export interface TenantActionOptions {
|
|
38
|
+
/** Permission required to run this action (checked against the active role). */
|
|
39
|
+
permission?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the app's typed `tenantAction`. Call once at app startup with the app's
|
|
43
|
+
* db, session→context resolver, and rbac instance.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createTenantActions(config: CreateTenantActionsConfig): <I = void, O = unknown>(options: TenantActionOptions, handler: TenantActionHandler<I, O>) => (input: I) => Promise<O>;
|
|
46
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAChD,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAE/D,kEAAkE;AAClE,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,cAAc,EAAE,MAAM,CAAA;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B;AAED,iFAAiF;AACjF,MAAM,WAAW,mBAAoB,SAAQ,aAAa;IACxD,iFAAiF;IACjF,KAAK,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC5C;AAED,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,SAAS,CAAA;IACb,sFAAsF;IACtF,gBAAgB,EAAE,MAAM,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IACrD;;;;;OAKG;IACH,IAAI,CAAC,EAAE;QAAE,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAA;IACnE,qFAAqF;IACrF,cAAc,CAAC,EAAE,MAAM,KAAK,CAAA;IAC5B,kFAAkF;IAClF,WAAW,CAAC,EAAE,MAAM,KAAK,CAAA;CAC1B;AAED,MAAM,MAAM,mBAAmB,CAAC,CAAC,EAAE,CAAC,IAAI,CACtC,IAAI,EAAE;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,EAAE,EAAE,SAAS,CAAA;CAAE,EACjD,KAAK,EAAE,CAAC,KACL,OAAO,CAAC,CAAC,CAAC,CAAA;AAEf,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,yBAAyB,IAGtC,CAAC,GAAG,IAAI,EAAE,CAAC,GAAG,OAAO,EAChD,SAAS,mBAAmB,EAC5B,SAAS,mBAAmB,CAAC,CAAC,EAAE,CAAC,CAAC,KACjC,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAiC5B"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @govcore/server — tenantAction, the enforced server-action seam.
|
|
2
|
+
//
|
|
3
|
+
// Every tenant-scoped mutation runs through here. tenantAction:
|
|
4
|
+
// 1. resolves the actor's active context (never trusts caller input),
|
|
5
|
+
// 2. applies a permission gate (if configured),
|
|
6
|
+
// 3. opens a transaction and sets the transaction-local org GUC, so the RLS
|
|
7
|
+
// policies in @govcore/schema apply to every query in the handler, and
|
|
8
|
+
// 4. hands the handler an audit fn pre-bound to the actor + org.
|
|
9
|
+
//
|
|
10
|
+
// The app builds its own typed `tenantAction` once via createTenantActions,
|
|
11
|
+
// injecting its db, its session→context resolver, and its rbac instance.
|
|
12
|
+
import { sql } from 'drizzle-orm';
|
|
13
|
+
import { writeAuditLog } from '@govcore/audit';
|
|
14
|
+
/**
|
|
15
|
+
* Build the app's typed `tenantAction`. Call once at app startup with the app's
|
|
16
|
+
* db, session→context resolver, and rbac instance.
|
|
17
|
+
*/
|
|
18
|
+
export function createTenantActions(config) {
|
|
19
|
+
const { db, getActiveContext, rbac } = config;
|
|
20
|
+
return function tenantAction(options, handler) {
|
|
21
|
+
return async (input) => {
|
|
22
|
+
const active = await getActiveContext();
|
|
23
|
+
if (!active) {
|
|
24
|
+
if (config.onUnauthorized)
|
|
25
|
+
return config.onUnauthorized();
|
|
26
|
+
throw new Error('Unauthorized');
|
|
27
|
+
}
|
|
28
|
+
if (options.permission) {
|
|
29
|
+
const allowed = rbac?.hasPermission(active.role, options.permission) ?? false;
|
|
30
|
+
if (!allowed) {
|
|
31
|
+
if (config.onForbidden)
|
|
32
|
+
return config.onForbidden();
|
|
33
|
+
throw new Error('Forbidden');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return db.transaction(async (tx) => {
|
|
37
|
+
// Transaction-local org GUC → RLS policies bind to every query below.
|
|
38
|
+
await tx.execute(sql `select set_config('app.current_org', ${active.organizationId}, true)`);
|
|
39
|
+
const txDb = tx;
|
|
40
|
+
const ctx = {
|
|
41
|
+
...active,
|
|
42
|
+
audit: (event) => writeAuditLog(txDb, {
|
|
43
|
+
...event,
|
|
44
|
+
userId: event.userId ?? active.userId,
|
|
45
|
+
organizationId: event.organizationId ?? active.organizationId,
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
return handler({ ctx, db: txDb }, input);
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@govcore/server",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "tenantAction — the server-action wrapper that opens the tenant transaction, sets the RLS org GUC, gates on permission, and binds audit",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"drizzle-orm": "^0.45.2",
|
|
19
|
+
"@govcore/audit": "0.1.0",
|
|
20
|
+
"@govcore/schema": "0.1.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.5.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"dev": "tsc -p tsconfig.json --watch"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @govcore/server — tenantAction, the enforced server-action seam.
|
|
2
|
+
//
|
|
3
|
+
// Every tenant-scoped mutation runs through here. tenantAction:
|
|
4
|
+
// 1. resolves the actor's active context (never trusts caller input),
|
|
5
|
+
// 2. applies a permission gate (if configured),
|
|
6
|
+
// 3. opens a transaction and sets the transaction-local org GUC, so the RLS
|
|
7
|
+
// policies in @govcore/schema apply to every query in the handler, and
|
|
8
|
+
// 4. hands the handler an audit fn pre-bound to the actor + org.
|
|
9
|
+
//
|
|
10
|
+
// The app builds its own typed `tenantAction` once via createTenantActions,
|
|
11
|
+
// injecting its db, its session→context resolver, and its rbac instance.
|
|
12
|
+
|
|
13
|
+
import { sql } from 'drizzle-orm'
|
|
14
|
+
import { type GovcoreDb } from '@govcore/schema'
|
|
15
|
+
import { writeAuditLog, type AuditEvent } from '@govcore/audit'
|
|
16
|
+
|
|
17
|
+
/** The trusted, server-resolved identity of the current actor. */
|
|
18
|
+
export interface ActiveContext {
|
|
19
|
+
userId: string
|
|
20
|
+
organizationId: string
|
|
21
|
+
role: string
|
|
22
|
+
instanceRole?: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a handler receives: the active context plus a pre-bound audit writer. */
|
|
26
|
+
export interface TenantActionContext extends ActiveContext {
|
|
27
|
+
/** Write an audit row in the *same* transaction, pre-filled with actor + org. */
|
|
28
|
+
audit: (event: AuditEvent) => Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CreateTenantActionsConfig {
|
|
32
|
+
db: GovcoreDb
|
|
33
|
+
/** Resolve the current actor's active context — typically from the auth() session. */
|
|
34
|
+
getActiveContext: () => Promise<ActiveContext | null>
|
|
35
|
+
/**
|
|
36
|
+
* Optional permission gate — pass an `@govcore/rbac` instance directly. Method
|
|
37
|
+
* syntax (not an arrow property) so its bivariant parameters accept a
|
|
38
|
+
* `createRbac<Role, Permission>()` whose `hasPermission` is typed with the
|
|
39
|
+
* app's role/permission *literals*, while the active role here is a `string`.
|
|
40
|
+
*/
|
|
41
|
+
rbac?: { hasPermission(role: string, permission: string): boolean }
|
|
42
|
+
/** Called when there is no active context. Default: throws Error('Unauthorized'). */
|
|
43
|
+
onUnauthorized?: () => never
|
|
44
|
+
/** Called when the permission check fails. Default: throws Error('Forbidden'). */
|
|
45
|
+
onForbidden?: () => never
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TenantActionHandler<I, O> = (
|
|
49
|
+
args: { ctx: TenantActionContext; db: GovcoreDb },
|
|
50
|
+
input: I,
|
|
51
|
+
) => Promise<O>
|
|
52
|
+
|
|
53
|
+
export interface TenantActionOptions {
|
|
54
|
+
/** Permission required to run this action (checked against the active role). */
|
|
55
|
+
permission?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the app's typed `tenantAction`. Call once at app startup with the app's
|
|
60
|
+
* db, session→context resolver, and rbac instance.
|
|
61
|
+
*/
|
|
62
|
+
export function createTenantActions(config: CreateTenantActionsConfig) {
|
|
63
|
+
const { db, getActiveContext, rbac } = config
|
|
64
|
+
|
|
65
|
+
return function tenantAction<I = void, O = unknown>(
|
|
66
|
+
options: TenantActionOptions,
|
|
67
|
+
handler: TenantActionHandler<I, O>,
|
|
68
|
+
): (input: I) => Promise<O> {
|
|
69
|
+
return async (input: I): Promise<O> => {
|
|
70
|
+
const active = await getActiveContext()
|
|
71
|
+
if (!active) {
|
|
72
|
+
if (config.onUnauthorized) return config.onUnauthorized()
|
|
73
|
+
throw new Error('Unauthorized')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (options.permission) {
|
|
77
|
+
const allowed = rbac?.hasPermission(active.role, options.permission) ?? false
|
|
78
|
+
if (!allowed) {
|
|
79
|
+
if (config.onForbidden) return config.onForbidden()
|
|
80
|
+
throw new Error('Forbidden')
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return db.transaction(async (tx) => {
|
|
85
|
+
// Transaction-local org GUC → RLS policies bind to every query below.
|
|
86
|
+
await tx.execute(sql`select set_config('app.current_org', ${active.organizationId}, true)`)
|
|
87
|
+
const txDb = tx as unknown as GovcoreDb
|
|
88
|
+
const ctx: TenantActionContext = {
|
|
89
|
+
...active,
|
|
90
|
+
audit: (event) =>
|
|
91
|
+
writeAuditLog(txDb, {
|
|
92
|
+
...event,
|
|
93
|
+
userId: event.userId ?? active.userId,
|
|
94
|
+
organizationId: event.organizationId ?? active.organizationId,
|
|
95
|
+
}),
|
|
96
|
+
}
|
|
97
|
+
return handler({ ctx, db: txDb }, input)
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|