okengine 0.16.0 → 0.17.0
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/manifest.v1.schema.json +21 -2
- package/package.json +1 -1
- package/site/content/docs/elements/clock.mdx +7 -0
- package/site/content/docs/elements/flow.mdx +14 -12
- package/site/content/docs/elements/gate.mdx +58 -8
- package/site/content/docs/elements/signal.mdx +46 -17
- package/site/content/docs/elements/store.mdx +23 -17
- package/site/content/docs/elements/vault.mdx +23 -0
- package/site/content/docs/get-started/project-structure.mdx +4 -4
- package/site/content/docs/reference/cli.md +1 -1
- package/site/content/docs/reference/client.mdx +72 -17
- package/site/content/docs/reference/configuration.mdx +7 -4
- package/site/content/docs/reference/errors.mdx +24 -17
- package/site/content/docs/reference/fx.mdx +15 -9
- package/src/auth/api-key-sql.ts +11 -4
- package/src/auth/api-keys.ts +3 -0
- package/src/auth/config.ts +19 -0
- package/src/auth/index.ts +37 -0
- package/src/auth/plugin.ts +6 -1
- package/src/auth/sessions.ts +18 -0
- package/src/auth/tables.ts +32 -0
- package/src/auth/tenant-config.ts +74 -0
- package/src/auth/tenant-tables.ts +11 -0
- package/src/auth/tenants.test.ts +63 -0
- package/src/auth/tenants.ts +360 -0
- package/src/cli/build.ts +2 -2
- package/src/client/budget.test.ts +1 -1
- package/src/client/create.ts +75 -5
- package/src/client/index.ts +13 -0
- package/src/client/live.test.ts +422 -0
- package/src/client/live.ts +389 -0
- package/src/client/notes-contract.test.ts +41 -0
- package/src/client/types.ts +85 -6
- package/src/client-react/index.ts +85 -1
- package/src/client-react/use-live.test.ts +129 -0
- package/src/compiler/effects-infer.ts +22 -2
- package/src/compiler/extract.test.ts +143 -11
- package/src/compiler/extract.ts +210 -30
- package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
- package/src/compiler/fixtures/skyport.expected.json +2 -3
- package/src/compiler/response.ts +41 -11
- package/src/console/server/app.ts +44 -11
- package/src/console/server/console.test.ts +6 -8
- package/src/console/server/gates.ts +2 -9
- package/src/console/server/store.test.ts +17 -0
- package/src/console/server/store.ts +43 -1
- package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
- package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
- package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
- package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
- package/src/drivers/index.ts +2 -0
- package/src/drivers/journal-postgres.ts +12 -3
- package/src/drivers/pg-rls.ts +26 -2
- package/src/drivers/pg-vault-rls.ts +68 -0
- package/src/drivers/signal-engine.ts +69 -15
- package/src/drivers/signal-live-iter.ts +65 -0
- package/src/drivers/signal-nats.ts +2 -1
- package/src/drivers/signal-postgres.ts +95 -12
- package/src/drivers/signal-redis.ts +2 -1
- package/src/drivers/signal-retention.ts +64 -0
- package/src/drivers/signal-types.ts +35 -5
- package/src/elements/clock/declare.ts +64 -2
- package/src/elements/clock/reconcile.ts +98 -25
- package/src/elements/clock/runtime.ts +13 -2
- package/src/elements/clock.test.ts +28 -0
- package/src/elements/clock.ts +9 -1
- package/src/elements/gate/permissions.ts +12 -0
- package/src/elements/gate.ts +6 -1
- package/src/elements/signal/declare.ts +44 -10
- package/src/elements/signal/delivery-modes.test.ts +90 -4
- package/src/elements/signal/order-lifecycle.test.ts +20 -4
- package/src/elements/signal/runtime.ts +42 -0
- package/src/elements/signal.test.ts +21 -8
- package/src/elements/signal.ts +1 -1
- package/src/elements/store/declare.ts +7 -0
- package/src/elements/store/rls-identity.test.ts +29 -0
- package/src/elements/store/rls-identity.ts +3 -0
- package/src/elements/store/schema-decl.ts +63 -3
- package/src/elements/store/schema-tenant.ts +41 -0
- package/src/elements/store/sql-rls-isolation.test.ts +39 -0
- package/src/elements/store.ts +2 -0
- package/src/elements/vault/builtin-adapter.ts +15 -2
- package/src/elements/vault/declare.ts +8 -0
- package/src/elements/vault/runtime.ts +7 -0
- package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
- package/src/elements/vault/storage.ts +9 -0
- package/src/elements/vault/test-helpers.ts +2 -1
- package/src/i18n/catalogs/ar.ts +19 -0
- package/src/i18n/catalogs/en.ts +19 -0
- package/src/index.ts +1 -0
- package/src/kernel/adopt-routes.ts +56 -8
- package/src/kernel/app-tenant.ts +122 -0
- package/src/kernel/app.ts +183 -56
- package/src/kernel/auth-resolve.ts +3 -0
- package/src/kernel/boot-bind/clock.ts +9 -3
- package/src/kernel/boot.ts +2 -0
- package/src/kernel/budget.test.ts +1 -1
- package/src/kernel/clock-durable.ts +8 -0
- package/src/kernel/clock-per-tenant-name.ts +5 -0
- package/src/kernel/clock-reconcile.ts +8 -0
- package/src/kernel/errors-live-resume.ts +15 -0
- package/src/kernel/errors-tenant.ts +29 -0
- package/src/kernel/errors.registry.test.ts +31 -3
- package/src/kernel/errors.ts +43 -3
- package/src/kernel/flow.ts +26 -5
- package/src/kernel/fx-auth-keys.ts +6 -1
- package/src/kernel/fx-auth-tenants.test.ts +87 -0
- package/src/kernel/fx-auth-tenants.ts +286 -0
- package/src/kernel/fx-live-stream.ts +149 -0
- package/src/kernel/fx-live.test.ts +157 -0
- package/src/kernel/fx-runtime.ts +15 -0
- package/src/kernel/fx-tenant-store.ts +213 -0
- package/src/kernel/fx.test.ts +91 -0
- package/src/kernel/fx.ts +173 -13
- package/src/kernel/hooks.ts +2 -2
- package/src/kernel/http-resource.ts +9 -18
- package/src/kernel/index.ts +2 -0
- package/src/kernel/journal.ts +12 -0
- package/src/kernel/live-http.test.ts +78 -0
- package/src/kernel/live-http.ts +114 -0
- package/src/kernel/live-resume.test.ts +125 -0
- package/src/kernel/on.ts +51 -0
- package/src/kernel/pipeline-tenant.ts +49 -0
- package/src/kernel/pipeline.test.ts +1 -1
- package/src/kernel/pipeline.ts +37 -2
- package/src/kernel/resource-mount.test.ts +10 -27
- package/src/kernel/tenant-resolve.test.ts +101 -0
- package/src/kernel/tenant-resolve.ts +124 -0
- package/src/kernel/tenant-roles.test.ts +87 -0
- package/src/kernel/triggers.ts +59 -21
- package/src/manifest/diff.test.ts +11 -2
- package/src/manifest/diff.ts +53 -11
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +0 -1
- package/src/manifest/types.ts +52 -6
- package/src/release/build-lib.ts +13 -1
- package/src/release/limits.ts +2 -2
- package/src/release/measure.ts +55 -1
- package/src/client/live-gap.test.ts +0 -35
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant registry table names — kept off AUTH_TABLES so Store-only graphs
|
|
3
|
+
* that already ship `oke_api_keys` do not also pin `oke_tenants`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Opt-in via `gate.auth.tenant`. */
|
|
7
|
+
export const AUTH_TENANT_TABLES = {
|
|
8
|
+
tenants: "oke_tenants",
|
|
9
|
+
tenantMembers: "oke_tenant_members",
|
|
10
|
+
tenantRoles: "oke_tenant_roles",
|
|
11
|
+
} as const;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant registry — user-plane catalog on write; membership helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { isApplicationScope } from "../elements/gate/permissions.ts";
|
|
7
|
+
import {
|
|
8
|
+
createTenant,
|
|
9
|
+
createTenantStore,
|
|
10
|
+
tenantRoleScopeFailure,
|
|
11
|
+
upsertTenantRole,
|
|
12
|
+
} from "./tenants.ts";
|
|
13
|
+
|
|
14
|
+
describe("tenantRoleScopeFailure", () => {
|
|
15
|
+
const catalog = ["booking:create", "member", "console:store.sql:write", "console:*"];
|
|
16
|
+
|
|
17
|
+
test("unknown names and console:* fail the same way", () => {
|
|
18
|
+
const invented = tenantRoleScopeFailure(["this:was:never:declared"], catalog);
|
|
19
|
+
const consoleWrite = tenantRoleScopeFailure(["console:store.sql:write"], catalog);
|
|
20
|
+
const consoleStar = tenantRoleScopeFailure(["console:*"], catalog);
|
|
21
|
+
expect(invented?.reason).toBe("unknown_scope");
|
|
22
|
+
expect(consoleWrite?.reason).toBe("unknown_scope");
|
|
23
|
+
expect(consoleStar?.reason).toBe("unknown_scope");
|
|
24
|
+
expect(isApplicationScope("console:store.sql:write")).toBe(false);
|
|
25
|
+
expect(isApplicationScope("console:*")).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("application catalog names are grantable", () => {
|
|
29
|
+
expect(tenantRoleScopeFailure(["booking:create", "member"], catalog)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("upsertTenantRole", () => {
|
|
34
|
+
test("console-shaped upsert throws like an invented name", async () => {
|
|
35
|
+
const store = createTenantStore();
|
|
36
|
+
await createTenant(store, { name: "Acme", createdBy: "u1", id: "t1" });
|
|
37
|
+
const catalog = ["booking:create", "console:store.sql:write", "console:*"];
|
|
38
|
+
expect(() =>
|
|
39
|
+
upsertTenantRole(store, {
|
|
40
|
+
tenantId: "t1",
|
|
41
|
+
roleName: "admin",
|
|
42
|
+
scopes: ["console:store.sql:write"],
|
|
43
|
+
catalog,
|
|
44
|
+
}),
|
|
45
|
+
).toThrow(/unknown or operator-plane scope/);
|
|
46
|
+
expect(() =>
|
|
47
|
+
upsertTenantRole(store, {
|
|
48
|
+
tenantId: "t1",
|
|
49
|
+
roleName: "admin",
|
|
50
|
+
scopes: ["console:*"],
|
|
51
|
+
catalog,
|
|
52
|
+
}),
|
|
53
|
+
).toThrow(/unknown or operator-plane scope/);
|
|
54
|
+
expect(() =>
|
|
55
|
+
upsertTenantRole(store, {
|
|
56
|
+
tenantId: "t1",
|
|
57
|
+
roleName: "admin",
|
|
58
|
+
scopes: ["this:was:never:declared"],
|
|
59
|
+
catalog,
|
|
60
|
+
}),
|
|
61
|
+
).toThrow(/unknown or operator-plane scope/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in tenant registry + N:N membership + tenant-scoped roles.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in via `gate.auth.tenant`. Not an organizations product — no
|
|
5
|
+
* invitations UI, billing, or admin portal.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isApplicationScope } from "../elements/gate/permissions.ts";
|
|
9
|
+
import type { TenantMemberRow, TenantRoleRow, TenantRow } from "./tables.ts";
|
|
10
|
+
|
|
11
|
+
export type { TenantMemberRow, TenantRoleRow, TenantRow };
|
|
12
|
+
|
|
13
|
+
/** Capability / Manifest resource for `fx.auth` tenant methods. */
|
|
14
|
+
export const AUTH_TENANTS_RESOURCE = "auth:tenants";
|
|
15
|
+
|
|
16
|
+
/** Tenant registry / membership / role error. */
|
|
17
|
+
export class TenantError extends Error {
|
|
18
|
+
readonly reason: string;
|
|
19
|
+
readonly scope?: string;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param reason - Stable reason token
|
|
23
|
+
* @param message - Diagnostic
|
|
24
|
+
* @param scope - Illegal scope name when `unknown_scope`
|
|
25
|
+
*/
|
|
26
|
+
constructor(reason: string, message: string, scope?: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "TenantError";
|
|
29
|
+
this.reason = reason;
|
|
30
|
+
if (scope !== undefined) this.scope = scope;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Default member role name until a mapping exists. */
|
|
35
|
+
export const DEFAULT_TENANT_ROLE = "member";
|
|
36
|
+
|
|
37
|
+
/** Lifecycle hooks for Clock per-tenant row expansion. */
|
|
38
|
+
export interface TenantStoreHooks {
|
|
39
|
+
readonly onCreate?: (tenant: TenantRow) => void | Promise<void>;
|
|
40
|
+
readonly onDelete?: (tenant: TenantRow) => void | Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** In-memory tenant store. */
|
|
44
|
+
export interface TenantStore {
|
|
45
|
+
tenants: Map<string, TenantRow>;
|
|
46
|
+
members: Map<string, TenantMemberRow>;
|
|
47
|
+
roles: Map<string, TenantRoleRow>;
|
|
48
|
+
hooks?: TenantStoreHooks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create an empty tenant store.
|
|
53
|
+
*
|
|
54
|
+
* @param hooks - Optional create/delete hooks (Clock expansion)
|
|
55
|
+
*/
|
|
56
|
+
export function createTenantStore(hooks?: TenantStoreHooks): TenantStore {
|
|
57
|
+
return {
|
|
58
|
+
tenants: new Map(),
|
|
59
|
+
members: new Map(),
|
|
60
|
+
roles: new Map(),
|
|
61
|
+
...(hooks !== undefined ? { hooks } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function memberKey(tenantId: string, userId: string): string {
|
|
66
|
+
return `${tenantId}:${userId}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function roleKey(tenantId: string, roleName: string): string {
|
|
70
|
+
return `${tenantId}:${roleName}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Public tenant row returned by `fx.auth.listTenants`. */
|
|
74
|
+
export interface TenantPublicRow {
|
|
75
|
+
readonly id: string;
|
|
76
|
+
readonly name: string;
|
|
77
|
+
readonly slug: string | null;
|
|
78
|
+
readonly role: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Options for {@link createTenant}. */
|
|
82
|
+
export interface CreateTenantOptions {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly createdBy: string;
|
|
85
|
+
readonly slug?: string | null;
|
|
86
|
+
readonly id?: string;
|
|
87
|
+
readonly now?: () => number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a tenant and add the creator as a member.
|
|
92
|
+
*
|
|
93
|
+
* @param store - Tenant store
|
|
94
|
+
* @param options - Name / creator / optional id
|
|
95
|
+
*/
|
|
96
|
+
export async function createTenant(
|
|
97
|
+
store: TenantStore,
|
|
98
|
+
options: CreateTenantOptions,
|
|
99
|
+
): Promise<TenantRow> {
|
|
100
|
+
const now = options.now ?? (() => Date.now());
|
|
101
|
+
const t = now();
|
|
102
|
+
const id = options.id ?? crypto.randomUUID();
|
|
103
|
+
if (store.tenants.has(id)) {
|
|
104
|
+
throw new TenantError("tenant_exists", `tenant already exists: ${id}`);
|
|
105
|
+
}
|
|
106
|
+
const slug = options.slug ?? null;
|
|
107
|
+
if (slug) {
|
|
108
|
+
for (const row of store.tenants.values()) {
|
|
109
|
+
if (row.slug === slug) {
|
|
110
|
+
throw new TenantError("slug_taken", `tenant slug already taken: ${slug}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const row: TenantRow = {
|
|
115
|
+
id,
|
|
116
|
+
name: options.name,
|
|
117
|
+
slug,
|
|
118
|
+
createdAt: t,
|
|
119
|
+
createdBy: options.createdBy,
|
|
120
|
+
};
|
|
121
|
+
store.tenants.set(id, row);
|
|
122
|
+
addMember(store, {
|
|
123
|
+
tenantId: id,
|
|
124
|
+
userId: options.createdBy,
|
|
125
|
+
role: DEFAULT_TENANT_ROLE,
|
|
126
|
+
now,
|
|
127
|
+
});
|
|
128
|
+
await store.hooks?.onCreate?.(row);
|
|
129
|
+
return row;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Delete a tenant, its memberships, and its custom roles.
|
|
134
|
+
*
|
|
135
|
+
* @param store - Tenant store
|
|
136
|
+
* @param id - Tenant id
|
|
137
|
+
*/
|
|
138
|
+
export async function deleteTenant(store: TenantStore, id: string): Promise<TenantRow | undefined> {
|
|
139
|
+
const row = store.tenants.get(id);
|
|
140
|
+
if (!row) return undefined;
|
|
141
|
+
store.tenants.delete(id);
|
|
142
|
+
for (const [key, member] of [...store.members.entries()]) {
|
|
143
|
+
if (member.tenantId === id) store.members.delete(key);
|
|
144
|
+
}
|
|
145
|
+
for (const [key, role] of [...store.roles.entries()]) {
|
|
146
|
+
if (role.tenantId === id) store.roles.delete(key);
|
|
147
|
+
}
|
|
148
|
+
await store.hooks?.onDelete?.(row);
|
|
149
|
+
return row;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Options for {@link addMember}. */
|
|
153
|
+
export interface AddMemberOptions {
|
|
154
|
+
readonly tenantId: string;
|
|
155
|
+
readonly userId: string;
|
|
156
|
+
readonly role?: string;
|
|
157
|
+
readonly id?: string;
|
|
158
|
+
readonly now?: () => number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Add a membership (idempotent on tenant+user).
|
|
163
|
+
*
|
|
164
|
+
* @param store - Tenant store
|
|
165
|
+
* @param options - Membership fields
|
|
166
|
+
*/
|
|
167
|
+
export function addMember(store: TenantStore, options: AddMemberOptions): TenantMemberRow {
|
|
168
|
+
if (!store.tenants.has(options.tenantId)) {
|
|
169
|
+
throw new TenantError("unknown_tenant", `unknown tenant: ${options.tenantId}`);
|
|
170
|
+
}
|
|
171
|
+
const key = memberKey(options.tenantId, options.userId);
|
|
172
|
+
const existing = store.members.get(key);
|
|
173
|
+
if (existing) {
|
|
174
|
+
if (options.role !== undefined && options.role !== existing.role) {
|
|
175
|
+
const next: TenantMemberRow = { ...existing, role: options.role };
|
|
176
|
+
store.members.set(key, next);
|
|
177
|
+
return next;
|
|
178
|
+
}
|
|
179
|
+
return existing;
|
|
180
|
+
}
|
|
181
|
+
const now = options.now ?? (() => Date.now());
|
|
182
|
+
const row: TenantMemberRow = {
|
|
183
|
+
id: options.id ?? crypto.randomUUID(),
|
|
184
|
+
tenantId: options.tenantId,
|
|
185
|
+
userId: options.userId,
|
|
186
|
+
role: options.role ?? DEFAULT_TENANT_ROLE,
|
|
187
|
+
createdAt: now(),
|
|
188
|
+
};
|
|
189
|
+
store.members.set(key, row);
|
|
190
|
+
return row;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Remove a membership.
|
|
195
|
+
*
|
|
196
|
+
* @param store - Tenant store
|
|
197
|
+
* @param tenantId - Tenant id
|
|
198
|
+
* @param userId - User id
|
|
199
|
+
*/
|
|
200
|
+
export function removeMember(
|
|
201
|
+
store: TenantStore,
|
|
202
|
+
tenantId: string,
|
|
203
|
+
userId: string,
|
|
204
|
+
): TenantMemberRow | undefined {
|
|
205
|
+
const key = memberKey(tenantId, userId);
|
|
206
|
+
const row = store.members.get(key);
|
|
207
|
+
if (!row) return undefined;
|
|
208
|
+
store.members.delete(key);
|
|
209
|
+
return row;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Look up one membership.
|
|
214
|
+
*
|
|
215
|
+
* @param store - Tenant store
|
|
216
|
+
* @param tenantId - Tenant id
|
|
217
|
+
* @param userId - User id
|
|
218
|
+
*/
|
|
219
|
+
export function getMember(
|
|
220
|
+
store: TenantStore,
|
|
221
|
+
tenantId: string,
|
|
222
|
+
userId: string,
|
|
223
|
+
): TenantMemberRow | undefined {
|
|
224
|
+
return store.members.get(memberKey(tenantId, userId));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether `userId` is a member of `tenantId`.
|
|
229
|
+
*
|
|
230
|
+
* @param store - Tenant store
|
|
231
|
+
* @param tenantId - Tenant id
|
|
232
|
+
* @param userId - User id
|
|
233
|
+
*/
|
|
234
|
+
export function isMember(store: TenantStore, tenantId: string, userId: string): boolean {
|
|
235
|
+
return store.members.has(memberKey(tenantId, userId));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* List memberships for a user (for `listTenants`).
|
|
240
|
+
*
|
|
241
|
+
* @param store - Tenant store
|
|
242
|
+
* @param userId - User id
|
|
243
|
+
*/
|
|
244
|
+
export function listTenantsForUser(store: TenantStore, userId: string): TenantPublicRow[] {
|
|
245
|
+
const out: TenantPublicRow[] = [];
|
|
246
|
+
for (const member of store.members.values()) {
|
|
247
|
+
if (member.userId !== userId) continue;
|
|
248
|
+
const tenant = store.tenants.get(member.tenantId);
|
|
249
|
+
if (!tenant) continue;
|
|
250
|
+
out.push({
|
|
251
|
+
id: tenant.id,
|
|
252
|
+
name: tenant.name,
|
|
253
|
+
slug: tenant.slug,
|
|
254
|
+
role: member.role,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* List members of a tenant.
|
|
262
|
+
*
|
|
263
|
+
* @param store - Tenant store
|
|
264
|
+
* @param tenantId - Tenant id
|
|
265
|
+
*/
|
|
266
|
+
export function listMembers(store: TenantStore, tenantId: string): TenantMemberRow[] {
|
|
267
|
+
const out: TenantMemberRow[] = [];
|
|
268
|
+
for (const member of store.members.values()) {
|
|
269
|
+
if (member.tenantId === tenantId) out.push(member);
|
|
270
|
+
}
|
|
271
|
+
return out.sort((a, b) => a.userId.localeCompare(b.userId));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* All tenant ids currently in the store (Clock reconcile expansion).
|
|
276
|
+
*
|
|
277
|
+
* @param store - Tenant store
|
|
278
|
+
*/
|
|
279
|
+
export function listTenantIds(store: TenantStore): string[] {
|
|
280
|
+
return [...store.tenants.keys()].sort();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Options for {@link upsertTenantRole}. */
|
|
284
|
+
export interface UpsertTenantRoleOptions {
|
|
285
|
+
readonly tenantId: string;
|
|
286
|
+
readonly roleName: string;
|
|
287
|
+
readonly scopes: readonly string[];
|
|
288
|
+
readonly catalog: readonly string[];
|
|
289
|
+
readonly now?: () => number;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Validate scopes against the Manifest-derived **user-plane** catalog.
|
|
294
|
+
*
|
|
295
|
+
* Unknown names and `console:*` / `console:…` fail the same way.
|
|
296
|
+
*
|
|
297
|
+
* @param scopes - Requested scope names
|
|
298
|
+
* @param catalog - `deriveModuleActions(manifest)`
|
|
299
|
+
* @returns Failure when any name is illegal; `null` when all are grantable
|
|
300
|
+
*/
|
|
301
|
+
export function tenantRoleScopeFailure(
|
|
302
|
+
scopes: readonly string[],
|
|
303
|
+
catalog: readonly string[],
|
|
304
|
+
): TenantError | null {
|
|
305
|
+
const allowed = new Set(catalog.filter(isApplicationScope));
|
|
306
|
+
for (const scope of scopes) {
|
|
307
|
+
if (!allowed.has(scope)) {
|
|
308
|
+
return new TenantError("unknown_scope", `unknown or operator-plane scope: ${scope}`, scope);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Create or replace a tenant role mapping.
|
|
316
|
+
*
|
|
317
|
+
* @param store - Tenant store
|
|
318
|
+
* @param options - Role + catalog
|
|
319
|
+
*/
|
|
320
|
+
export function upsertTenantRole(
|
|
321
|
+
store: TenantStore,
|
|
322
|
+
options: UpsertTenantRoleOptions,
|
|
323
|
+
): TenantRoleRow {
|
|
324
|
+
if (!store.tenants.has(options.tenantId)) {
|
|
325
|
+
throw new TenantError("unknown_tenant", `unknown tenant: ${options.tenantId}`);
|
|
326
|
+
}
|
|
327
|
+
const denied = tenantRoleScopeFailure(options.scopes, options.catalog);
|
|
328
|
+
if (denied) throw denied;
|
|
329
|
+
const now = options.now ?? (() => Date.now());
|
|
330
|
+
const t = now();
|
|
331
|
+
const key = roleKey(options.tenantId, options.roleName);
|
|
332
|
+
const prev = store.roles.get(key);
|
|
333
|
+
const row: TenantRoleRow = {
|
|
334
|
+
tenantId: options.tenantId,
|
|
335
|
+
roleName: options.roleName,
|
|
336
|
+
scopes: [...options.scopes],
|
|
337
|
+
createdAt: prev?.createdAt ?? t,
|
|
338
|
+
updatedAt: t,
|
|
339
|
+
};
|
|
340
|
+
store.roles.set(key, row);
|
|
341
|
+
return row;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Expand a member's tenant role into application scopes.
|
|
346
|
+
*
|
|
347
|
+
* @param store - Tenant store
|
|
348
|
+
* @param tenantId - Tenant id
|
|
349
|
+
* @param userId - User id
|
|
350
|
+
*/
|
|
351
|
+
export function tenantScopesForMember(
|
|
352
|
+
store: TenantStore,
|
|
353
|
+
tenantId: string,
|
|
354
|
+
userId: string,
|
|
355
|
+
): readonly string[] {
|
|
356
|
+
const member = getMember(store, tenantId, userId);
|
|
357
|
+
if (!member) return [];
|
|
358
|
+
const role = store.roles.get(roleKey(tenantId, member.role));
|
|
359
|
+
return role?.scopes ?? [];
|
|
360
|
+
}
|
package/src/cli/build.ts
CHANGED
|
@@ -44,7 +44,7 @@ async function defaultSyncAdoptBarrel(rootDir: string): Promise<readonly string[
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Build a deployable bundle. Edge target aims at the <
|
|
47
|
+
* Build a deployable bundle. Edge target aims at the <16 kB kernel budget.
|
|
48
48
|
*
|
|
49
49
|
* @param options - Target / entry
|
|
50
50
|
*/
|
|
@@ -109,7 +109,7 @@ export async function buildCli(args: readonly string[]): Promise<number> {
|
|
|
109
109
|
else if (a === "--help" || a === "-h") {
|
|
110
110
|
console.log(`oke build [--target|-t bun|node|edge] [--entry|-e src/app.ts]
|
|
111
111
|
|
|
112
|
-
Tree-shaken bundle. --target edge aims at the <
|
|
112
|
+
Tree-shaken bundle. --target edge aims at the <16 kB kernel budget.
|
|
113
113
|
`);
|
|
114
114
|
return 0;
|
|
115
115
|
}
|
package/src/client/create.ts
CHANGED
|
@@ -14,7 +14,22 @@
|
|
|
14
14
|
|
|
15
15
|
import { createTransport, type Transport } from "./transport.ts";
|
|
16
16
|
import { asThenableIterable, attachPager } from "./pager.ts";
|
|
17
|
-
import
|
|
17
|
+
import {
|
|
18
|
+
flattenLiveRoutes,
|
|
19
|
+
isLiveHandlers,
|
|
20
|
+
pickLiveExposure,
|
|
21
|
+
subscribeLive,
|
|
22
|
+
type LiveByFlow,
|
|
23
|
+
type LiveRouteTable,
|
|
24
|
+
} from "./live.ts";
|
|
25
|
+
import type {
|
|
26
|
+
Client,
|
|
27
|
+
ClientLive,
|
|
28
|
+
ClientOptions,
|
|
29
|
+
ClientRouteMap,
|
|
30
|
+
LiveHandlers,
|
|
31
|
+
ResolveApp,
|
|
32
|
+
} from "./types.ts";
|
|
18
33
|
|
|
19
34
|
/** App-shaped value that carries a runtime `$routes` table from typed adopt. */
|
|
20
35
|
export interface AppWithRoutes {
|
|
@@ -80,10 +95,23 @@ export function createClient(
|
|
|
80
95
|
function buildClient(url: string, opts: ClientOptions = {}): Client {
|
|
81
96
|
const base = url.replace(/\/+$/, "");
|
|
82
97
|
const routes = opts.routes ?? flattenRoutes(opts.$routes);
|
|
98
|
+
const live = flattenLiveRoutes(opts.$routes);
|
|
83
99
|
const transport = createTransport(base, { ...opts, routes });
|
|
84
|
-
return proxy(transport, []
|
|
100
|
+
return proxy(transport, [], {
|
|
101
|
+
base,
|
|
102
|
+
opts,
|
|
103
|
+
liveBySignal: live.bySignal,
|
|
104
|
+
liveByFlow: live.byFlow,
|
|
105
|
+
}) as Client;
|
|
85
106
|
}
|
|
86
107
|
|
|
108
|
+
type ProxyCtx = {
|
|
109
|
+
readonly base: string;
|
|
110
|
+
readonly opts: ClientOptions;
|
|
111
|
+
readonly liveBySignal: LiveRouteTable;
|
|
112
|
+
readonly liveByFlow: LiveByFlow;
|
|
113
|
+
};
|
|
114
|
+
|
|
87
115
|
/**
|
|
88
116
|
* Flatten `app.$routes` into the transport REST table (`unit.flow` → method/path).
|
|
89
117
|
* Entries without both method and path are omitted (RPC fallback).
|
|
@@ -115,7 +143,7 @@ export function flattenRoutes(
|
|
|
115
143
|
* @param transport - HTTP transport
|
|
116
144
|
* @param path - Accumulated property path
|
|
117
145
|
*/
|
|
118
|
-
function proxy(transport: Transport, path: readonly string[]): unknown {
|
|
146
|
+
function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): unknown {
|
|
119
147
|
const invoke = async (input?: unknown) => {
|
|
120
148
|
if (path.length < 2) {
|
|
121
149
|
return attachPager(
|
|
@@ -137,7 +165,18 @@ function proxy(transport: Transport, path: readonly string[]): unknown {
|
|
|
137
165
|
const result = await transport.call(`${unit}/${flow}`, input);
|
|
138
166
|
return attachPager(result, invoke, input);
|
|
139
167
|
};
|
|
140
|
-
const call = (
|
|
168
|
+
const call = (a?: unknown, b?: unknown) => {
|
|
169
|
+
if (path.length >= 2) {
|
|
170
|
+
const key = `${path[0]}.${path.slice(1).join(".")}`;
|
|
171
|
+
const exposure = ctx.liveByFlow[key];
|
|
172
|
+
if (exposure && (isLiveHandlers(a) || isLiveHandlers(b))) {
|
|
173
|
+
const handlers = (isLiveHandlers(a) ? a : b) as LiveHandlers<unknown>;
|
|
174
|
+
const input = isLiveHandlers(a) ? undefined : a;
|
|
175
|
+
return subscribeLive(ctx.base, exposure, input, handlers, ctx.opts);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return asThenableIterable(invoke, a);
|
|
179
|
+
};
|
|
141
180
|
|
|
142
181
|
return new Proxy(call, {
|
|
143
182
|
get(_target, prop, receiver) {
|
|
@@ -145,7 +184,38 @@ function proxy(transport: Transport, path: readonly string[]): unknown {
|
|
|
145
184
|
return Reflect.get(_target, prop, receiver);
|
|
146
185
|
}
|
|
147
186
|
if (prop === "then") return undefined;
|
|
148
|
-
|
|
187
|
+
if (path.length === 0 && prop === "live") {
|
|
188
|
+
return makeLive(ctx);
|
|
189
|
+
}
|
|
190
|
+
return proxy(transport, [...path, prop], ctx);
|
|
149
191
|
},
|
|
150
192
|
});
|
|
151
193
|
}
|
|
194
|
+
|
|
195
|
+
function makeLive(ctx: ProxyCtx): ClientLive {
|
|
196
|
+
return ((signalOrName: unknown, inputOrHandlers: unknown, maybeHandlers?: unknown) => {
|
|
197
|
+
const name =
|
|
198
|
+
typeof signalOrName === "string"
|
|
199
|
+
? signalOrName
|
|
200
|
+
: signalOrName !== null &&
|
|
201
|
+
typeof signalOrName === "object" &&
|
|
202
|
+
"name" in signalOrName &&
|
|
203
|
+
typeof (signalOrName as { name: unknown }).name === "string"
|
|
204
|
+
? (signalOrName as { name: string }).name
|
|
205
|
+
: undefined;
|
|
206
|
+
if (!name) throw new Error("api.live requires a signal handle or name");
|
|
207
|
+
const handlers = isLiveHandlers(inputOrHandlers)
|
|
208
|
+
? inputOrHandlers
|
|
209
|
+
: isLiveHandlers(maybeHandlers)
|
|
210
|
+
? maybeHandlers
|
|
211
|
+
: undefined;
|
|
212
|
+
if (!handlers) throw new Error("api.live requires handlers with onEvent");
|
|
213
|
+
const input = isLiveHandlers(inputOrHandlers) ? undefined : inputOrHandlers;
|
|
214
|
+
const exposures = ctx.liveBySignal[name] ?? [];
|
|
215
|
+
if (exposures.length === 0) {
|
|
216
|
+
throw new Error(`No live HTTP exposure for signal "${name}"`);
|
|
217
|
+
}
|
|
218
|
+
const exposure = pickLiveExposure(exposures, input, handlers.via);
|
|
219
|
+
return subscribeLive(ctx.base, exposure, input, handlers, ctx.opts);
|
|
220
|
+
}) as ClientLive;
|
|
221
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
export {
|
|
9
|
+
flattenLiveRoutes,
|
|
10
|
+
isLiveHandlers,
|
|
11
|
+
LIVE_RESUBSCRIBE_INITIAL_MS,
|
|
12
|
+
LIVE_RESUBSCRIBE_MAX_MS,
|
|
13
|
+
nextResubscribeDelay,
|
|
14
|
+
pickLiveExposure,
|
|
15
|
+
} from "./live.ts";
|
|
8
16
|
export { createClient, flattenRoutes } from "./create.ts";
|
|
9
17
|
export type { AppWithRoutes } from "./create.ts";
|
|
10
18
|
export { isErrorCode, isFail, isOk, isTransportError } from "./errors.ts";
|
|
@@ -29,7 +37,12 @@ export type {
|
|
|
29
37
|
ClientResult,
|
|
30
38
|
ClientThenableIterable,
|
|
31
39
|
ClientRouteMap,
|
|
40
|
+
ClientLive,
|
|
32
41
|
FlowContract,
|
|
42
|
+
LiveHandlers,
|
|
43
|
+
LiveInput,
|
|
44
|
+
LiveSignalHandle,
|
|
45
|
+
LiveUnsubscribe,
|
|
33
46
|
Register,
|
|
34
47
|
ResolveApp,
|
|
35
48
|
RoutesOf,
|