@kindgi/platform 0.0.0-bootstrap.0 → 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.
Files changed (74) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +41 -1
  3. package/dist/in-memory/index.d.ts +9 -0
  4. package/dist/in-memory/index.d.ts.map +1 -0
  5. package/dist/in-memory/index.js +11 -0
  6. package/dist/in-memory/index.js.map +1 -0
  7. package/dist/in-memory/org-binding.d.ts +6 -0
  8. package/dist/in-memory/org-binding.d.ts.map +1 -0
  9. package/dist/in-memory/org-binding.js +81 -0
  10. package/dist/in-memory/org-binding.js.map +1 -0
  11. package/dist/in-memory/project-binding.d.ts +13 -0
  12. package/dist/in-memory/project-binding.d.ts.map +1 -0
  13. package/dist/in-memory/project-binding.js +286 -0
  14. package/dist/in-memory/project-binding.js.map +1 -0
  15. package/dist/in-memory/team-binding.d.ts +12 -0
  16. package/dist/in-memory/team-binding.d.ts.map +1 -0
  17. package/dist/in-memory/team-binding.js +170 -0
  18. package/dist/in-memory/team-binding.js.map +1 -0
  19. package/dist/in-memory/util.d.ts +27 -0
  20. package/dist/in-memory/util.d.ts.map +1 -0
  21. package/dist/in-memory/util.js +49 -0
  22. package/dist/in-memory/util.js.map +1 -0
  23. package/dist/index.d.ts +17 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +5 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/org-binding.d.ts +70 -0
  28. package/dist/org-binding.d.ts.map +1 -0
  29. package/dist/org-binding.js +4 -0
  30. package/dist/org-binding.js.map +1 -0
  31. package/dist/project-binding.d.ts +115 -0
  32. package/dist/project-binding.d.ts.map +1 -0
  33. package/dist/project-binding.js +4 -0
  34. package/dist/project-binding.js.map +1 -0
  35. package/dist/scope.d.ts +45 -0
  36. package/dist/scope.d.ts.map +1 -0
  37. package/dist/scope.js +13 -0
  38. package/dist/scope.js.map +1 -0
  39. package/dist/team-binding.d.ts +108 -0
  40. package/dist/team-binding.d.ts.map +1 -0
  41. package/dist/team-binding.js +4 -0
  42. package/dist/team-binding.js.map +1 -0
  43. package/dist/team-project-grant-binding.d.ts +49 -0
  44. package/dist/team-project-grant-binding.d.ts.map +1 -0
  45. package/dist/team-project-grant-binding.js +4 -0
  46. package/dist/team-project-grant-binding.js.map +1 -0
  47. package/dist/tenant-hierarchy-binding.d.ts +104 -0
  48. package/dist/tenant-hierarchy-binding.d.ts.map +1 -0
  49. package/dist/tenant-hierarchy-binding.js +4 -0
  50. package/dist/tenant-hierarchy-binding.js.map +1 -0
  51. package/dist/types.d.ts +129 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +6 -0
  54. package/dist/types.js.map +1 -0
  55. package/package.json +49 -4
  56. package/src/in-memory/index.ts +12 -0
  57. package/src/in-memory/org-binding.ts +101 -0
  58. package/src/in-memory/project-binding.ts +327 -0
  59. package/src/in-memory/team-binding.ts +205 -0
  60. package/src/in-memory/util.ts +72 -0
  61. package/src/index.ts +56 -0
  62. package/src/org-binding.ts +76 -0
  63. package/src/project-binding.ts +134 -0
  64. package/src/scope.ts +51 -0
  65. package/src/team-binding.ts +116 -0
  66. package/src/team-project-grant-binding.ts +65 -0
  67. package/src/tenant-hierarchy-binding.ts +107 -0
  68. package/src/types.ts +177 -0
  69. package/tests/in-memory-org-binding.test.ts +12 -0
  70. package/tests/in-memory-project-binding.test.ts +12 -0
  71. package/tests/in-memory-team-binding.test.ts +12 -0
  72. package/tests/org-binding.conformance.ts +165 -0
  73. package/tests/project-binding.conformance.ts +231 -0
  74. package/tests/team-binding.conformance.ts +175 -0
@@ -0,0 +1,205 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `makeInMemoryTeamBinding` — reference in-memory implementations of
6
+ * `TeamBinding` + `TeamMembershipBinding`. Used by tests + dev-time
7
+ * defaults.
8
+ *
9
+ * Storage: two maps — one keyed by `TeamId`, the other keyed by a
10
+ * composite `(teamId, userId)` string for memberships. Every op
11
+ * filters by `tenantId` at the row level. The membership map only
12
+ * stores memberships whose parent team belongs to the tenant — the
13
+ * `add` code path validates team existence before writing.
14
+ *
15
+ * Both factories are returned together so the two adapters share the
16
+ * same team map (a membership add must fail cleanly for a
17
+ * non-existent team; splitting into two disjoint factories would
18
+ * require an extra cross-binding contract).
19
+ */
20
+
21
+ import type { Filter, Page, TeamId, TenantId, UserId } from '@kindgi/types';
22
+
23
+ import type {
24
+ TeamBinding,
25
+ TeamListFilter,
26
+ TeamMembershipAddInput,
27
+ TeamMembershipBinding,
28
+ } from '../team-binding.js';
29
+ import type { Team, TeamMembership, TeamPatch, TeamRole, TeamSpec } from '../types.js';
30
+
31
+ import { nowTimestamp, paginate } from './util.js';
32
+
33
+ let teamIdCounter = 0;
34
+ function nextTeamId(): TeamId {
35
+ teamIdCounter += 1;
36
+ return `team-${teamIdCounter}` as TeamId;
37
+ }
38
+
39
+ function membershipKey(teamId: TeamId, userId: UserId): string {
40
+ return `${teamId}::${userId}`;
41
+ }
42
+
43
+ /**
44
+ * Combined factory — returns a `TeamBinding` + `TeamMembershipBinding`
45
+ * pair that share underlying storage. The pair is the atomic unit
46
+ * (memberships reference teams; the two must agree on tenant
47
+ * membership at every op).
48
+ */
49
+ export function makeInMemoryTeamBinding(): {
50
+ readonly teams: TeamBinding;
51
+ readonly memberships: TeamMembershipBinding;
52
+ } {
53
+ const teamRows = new Map<TeamId, Team>();
54
+ const membershipRows = new Map<string, TeamMembership>();
55
+
56
+ function findTeamInTenant(tenantId: TenantId, teamId: TeamId): Team | undefined {
57
+ const t = teamRows.get(teamId);
58
+ if (t === undefined || t.tenantId !== tenantId) return undefined;
59
+ return t;
60
+ }
61
+
62
+ const teams: TeamBinding = {
63
+ async create(tenantId, spec: TeamSpec): Promise<TeamId> {
64
+ const id = nextTeamId();
65
+ const now = nowTimestamp();
66
+ const row: Team = {
67
+ id,
68
+ tenantId,
69
+ name: spec.name,
70
+ slug: spec.slug,
71
+ createdAt: now,
72
+ updatedAt: now,
73
+ ...(spec.orgId !== undefined ? { orgId: spec.orgId } : {}),
74
+ ...(spec.description !== undefined ? { description: spec.description } : {}),
75
+ };
76
+ teamRows.set(id, row);
77
+ return id;
78
+ },
79
+
80
+ async get(tenantId, teamId): Promise<Team | undefined> {
81
+ return findTeamInTenant(tenantId, teamId);
82
+ },
83
+
84
+ async list(tenantId, filter: TeamListFilter): Promise<Page<Team>> {
85
+ const all: Team[] = [];
86
+ for (const row of teamRows.values()) {
87
+ if (row.tenantId !== tenantId) continue;
88
+ if (filter.orgId !== undefined && row.orgId !== filter.orgId) continue;
89
+ if (filter.nameContains !== undefined && !row.name.includes(filter.nameContains)) {
90
+ continue;
91
+ }
92
+ all.push(row);
93
+ }
94
+ all.sort((a, b) => {
95
+ if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
96
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
97
+ });
98
+ return paginate(all, filter.limit, filter.cursor);
99
+ },
100
+
101
+ async update(tenantId, teamId, patch: TeamPatch): Promise<void> {
102
+ const row = findTeamInTenant(tenantId, teamId);
103
+ if (row === undefined) {
104
+ throw new Error(`team-not-found: ${teamId}`);
105
+ }
106
+ // orgId patch tri-state: undefined = untouched; null = clear;
107
+ // OrgId value = set.
108
+ let nextOrgId = row.orgId;
109
+ if (patch.orgId !== undefined) {
110
+ nextOrgId = patch.orgId === null ? undefined : patch.orgId;
111
+ }
112
+ const nextDescription = patch.description ?? row.description;
113
+ const next: Team = {
114
+ id: row.id,
115
+ tenantId: row.tenantId,
116
+ name: patch.name ?? row.name,
117
+ slug: patch.slug ?? row.slug,
118
+ createdAt: row.createdAt,
119
+ updatedAt: nowTimestamp(),
120
+ ...(nextOrgId !== undefined ? { orgId: nextOrgId } : {}),
121
+ ...(nextDescription !== undefined ? { description: nextDescription } : {}),
122
+ };
123
+ teamRows.set(teamId, next);
124
+ },
125
+
126
+ async delete(tenantId, teamId): Promise<void> {
127
+ const row = findTeamInTenant(tenantId, teamId);
128
+ if (row === undefined) return;
129
+ teamRows.delete(teamId);
130
+ // Cascade — every membership row for this team drops with it.
131
+ for (const key of membershipRows.keys()) {
132
+ if (key.startsWith(`${teamId}::`)) {
133
+ membershipRows.delete(key);
134
+ }
135
+ }
136
+ },
137
+ };
138
+
139
+ const memberships: TeamMembershipBinding = {
140
+ async add(tenantId, input: TeamMembershipAddInput): Promise<void> {
141
+ const team = findTeamInTenant(tenantId, input.teamId);
142
+ if (team === undefined) {
143
+ throw new Error(`team-not-found: ${input.teamId}`);
144
+ }
145
+ const key = membershipKey(input.teamId, input.userId);
146
+ if (membershipRows.has(key)) {
147
+ // Idempotent — role mutation goes through updateRole.
148
+ return;
149
+ }
150
+ const row: TeamMembership = {
151
+ teamId: input.teamId,
152
+ userId: input.userId,
153
+ role: input.role,
154
+ joinedAt: nowTimestamp(),
155
+ };
156
+ membershipRows.set(key, row);
157
+ },
158
+
159
+ async remove(tenantId, teamId, userId): Promise<void> {
160
+ const team = findTeamInTenant(tenantId, teamId);
161
+ if (team === undefined) return;
162
+ membershipRows.delete(membershipKey(teamId, userId));
163
+ },
164
+
165
+ async list(tenantId, teamId, filter: Filter): Promise<Page<TeamMembership>> {
166
+ const team = findTeamInTenant(tenantId, teamId);
167
+ if (team === undefined) return { items: [] };
168
+ const all: TeamMembership[] = [];
169
+ for (const row of membershipRows.values()) {
170
+ if (row.teamId !== teamId) continue;
171
+ all.push(row);
172
+ }
173
+ all.sort((a, b) => (a.joinedAt < b.joinedAt ? -1 : a.joinedAt > b.joinedAt ? 1 : 0));
174
+ return paginate(all, filter.limit, filter.cursor);
175
+ },
176
+
177
+ async listForUser(tenantId, userId, filter: Filter): Promise<Page<TeamMembership>> {
178
+ const all: TeamMembership[] = [];
179
+ for (const row of membershipRows.values()) {
180
+ if (row.userId !== userId) continue;
181
+ // Confirm the parent team belongs to the tenant.
182
+ const t = teamRows.get(row.teamId);
183
+ if (t === undefined || t.tenantId !== tenantId) continue;
184
+ all.push(row);
185
+ }
186
+ all.sort((a, b) => (a.joinedAt < b.joinedAt ? -1 : a.joinedAt > b.joinedAt ? 1 : 0));
187
+ return paginate(all, filter.limit, filter.cursor);
188
+ },
189
+
190
+ async updateRole(tenantId, teamId, userId, role: TeamRole): Promise<void> {
191
+ const team = findTeamInTenant(tenantId, teamId);
192
+ if (team === undefined) {
193
+ throw new Error(`team-not-found: ${teamId}`);
194
+ }
195
+ const key = membershipKey(teamId, userId);
196
+ const row = membershipRows.get(key);
197
+ if (row === undefined) {
198
+ throw new Error(`team-membership-not-found: ${teamId}/${userId}`);
199
+ }
200
+ membershipRows.set(key, { ...row, role });
201
+ },
202
+ };
203
+
204
+ return { teams, memberships };
205
+ }
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * Shared helpers for the in-memory reference adapters.
6
+ *
7
+ * - `nowTimestamp()` — monotonic-ish `Timestamp` from `Date.now()`.
8
+ * Uses a per-call increment fallback so two calls in the same
9
+ * millisecond still produce a strictly-increasing pair (matters for
10
+ * `list` ordering by `createdAt`).
11
+ * - `paginate(all, limit, cursor)` — takes a sorted array + optional
12
+ * `limit` + optional opaque `Cursor` and returns a `Page<T>`. Cursor
13
+ * encoding is `String(offset)` — trivial for the in-memory adapter,
14
+ * opaque to callers per the `Cursor` doc-comment in
15
+ * `packages/types/src/filter.ts`.
16
+ */
17
+
18
+ import type { Cursor, Page, Timestamp } from '@kindgi/types';
19
+
20
+ // Monotonic counter so back-to-back calls inside a single millisecond
21
+ // still produce strictly-increasing `Timestamp` values. Kept module-
22
+ // local; not exposed.
23
+ let lastMs = 0;
24
+ let lastCounter = 0;
25
+
26
+ export function nowTimestamp(): Timestamp {
27
+ const ms = Date.now();
28
+ if (ms === lastMs) {
29
+ lastCounter += 1;
30
+ } else {
31
+ lastMs = ms;
32
+ lastCounter = 0;
33
+ }
34
+ const date = new Date(ms).toISOString();
35
+ // Encode the sub-ms counter as a trailing `#NNNN` suffix so the
36
+ // resulting string still sorts lexicographically in creation order —
37
+ // the in-memory adapter only compares strings.
38
+ const suffix = lastCounter === 0 ? '' : `#${String(lastCounter).padStart(4, '0')}`;
39
+ return `${date}${suffix}` as Timestamp;
40
+ }
41
+
42
+ /**
43
+ * Default page size when a caller passes no `limit` ("small default,
44
+ * high ceiling").
45
+ */
46
+ export const DEFAULT_PAGE_LIMIT = 100;
47
+
48
+ /**
49
+ * Maximum page size — clamps runaway `limit` values so a caller
50
+ * can't accidentally exfiltrate an entire table.
51
+ */
52
+ export const MAX_PAGE_LIMIT = 500;
53
+
54
+ export function paginate<T>(
55
+ all: readonly T[],
56
+ limit: number | undefined,
57
+ cursor: Cursor | undefined,
58
+ ): Page<T> {
59
+ const effectiveLimit = Math.min(Math.max(1, limit ?? DEFAULT_PAGE_LIMIT), MAX_PAGE_LIMIT);
60
+ const offset = cursor === undefined ? 0 : Number.parseInt(cursor, 10);
61
+ const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0;
62
+ const slice = all.slice(safeOffset, safeOffset + effectiveLimit);
63
+ const nextOffset = safeOffset + slice.length;
64
+ const hasMore = nextOffset < all.length;
65
+ if (hasMore) {
66
+ return {
67
+ items: slice,
68
+ nextCursor: String(nextOffset) as Cursor,
69
+ };
70
+ }
71
+ return { items: slice };
72
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `@kindgi/platform` — the multi-tenant hierarchy primitives. Ships
6
+ * the type surface + binding interfaces (plus reference in-memory
7
+ * adapters); durable implementations are supplied by the deployment.
8
+ * Authorization vocabulary (object types, actions, tuple builders, PEP
9
+ * helpers) lives in `@kindgi/authz`, not here.
10
+ */
11
+
12
+ export type * from './scope.js';
13
+ export type * from './types.js';
14
+ export { scopeKey } from './scope.js';
15
+
16
+ export type {
17
+ OrgBinding,
18
+ OrgListFilter,
19
+ } from './org-binding.js';
20
+ export type {
21
+ TeamBinding,
22
+ TeamListFilter,
23
+ TeamMembershipAddInput,
24
+ TeamMembershipBinding,
25
+ } from './team-binding.js';
26
+ export type {
27
+ ProjectBinding,
28
+ ProjectListFilter,
29
+ ProjectMembershipAddInput,
30
+ ProjectMembershipBinding,
31
+ } from './project-binding.js';
32
+ export type {
33
+ TeamProjectGrant,
34
+ TeamProjectGrantAddInput,
35
+ TeamProjectGrantBinding,
36
+ } from './team-project-grant-binding.js';
37
+
38
+ export {
39
+ makeInMemoryOrgBinding,
40
+ makeInMemoryTeamBinding,
41
+ makeInMemoryProjectBinding,
42
+ } from './in-memory/index.js';
43
+
44
+ export type {
45
+ AddProjectMemberParams,
46
+ AddTeamMemberParams,
47
+ CreateOrgError,
48
+ CreateOrgParams,
49
+ CreateProjectError,
50
+ CreateProjectParams,
51
+ CreateTeamError,
52
+ CreateTeamParams,
53
+ MembershipMutationError,
54
+ TenantHierarchyBinding,
55
+ TenantSummary,
56
+ } from './tenant-hierarchy-binding.js';
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `OrgBinding` — CRUD surface for the `Org` primitive within a tenant.
6
+ *
7
+ * Conventions shared by every binding in this package:
8
+ * - Every method takes `tenantId: TenantId` as its first argument. The
9
+ * binding is responsible for enforcing tenant isolation: a call from
10
+ * tenant A must never surface a row from tenant B.
11
+ * - `list` returns the shared `Page<T>` envelope from
12
+ * `@kindgi/types` (`items` + `nextCursor`).
13
+ * - `list` filters extend the shared `Filter` shape so every list call
14
+ * shares the same pagination fields.
15
+ *
16
+ * This package ships the interface + reference in-memory adapter
17
+ * (in `./in-memory/org-binding.ts`). Any adapter can be checked with
18
+ * the conformance suite in `packages/platform/tests/`
19
+ * (`runOrgBindingConformance`).
20
+ */
21
+
22
+ import type { Filter, OrgId, Page, TenantId } from '@kindgi/types';
23
+
24
+ import type { Org, OrgPatch, OrgSpec } from './types.js';
25
+
26
+ /**
27
+ * `list` filter for orgs. Extends the shared `Filter` with `orgs`-
28
+ * specific facets. Every additional field on the list contract lives
29
+ * here (not on the method signature) so the shape survives SDK codegen
30
+ * unchanged.
31
+ *
32
+ * - `nameContains` — case-sensitive substring match on `Org.name`.
33
+ * Optional; absent = no name filter. The `@kindgi/api` orgs route
34
+ * exposes it as `?nameContains=`.
35
+ */
36
+ export interface OrgListFilter extends Filter {
37
+ readonly nameContains?: string;
38
+ }
39
+
40
+ /**
41
+ * The org CRUD binding. Framework consumers implement one of these
42
+ * per persistence backend (Postgres, in-memory for tests).
43
+ */
44
+ export interface OrgBinding {
45
+ /**
46
+ * Create a new `Org` in the given tenant. Returns the freshly-
47
+ * assigned `OrgId`. The binding is responsible for allocating IDs,
48
+ * timestamps, and slug-uniqueness enforcement within the tenant.
49
+ */
50
+ create(tenantId: TenantId, spec: OrgSpec): Promise<OrgId>;
51
+ /**
52
+ * Look up an `Org` by id. Returns `undefined` when no row exists in
53
+ * the given tenant, or when the row exists in a different tenant
54
+ * (tenant isolation is by construction, never a silent leak).
55
+ */
56
+ get(tenantId: TenantId, orgId: OrgId): Promise<Org | undefined>;
57
+ /**
58
+ * Cursor-paginated list of orgs within a tenant. Uses the shared
59
+ * `Page<Org>` envelope from `@kindgi/types` — consumers loop on
60
+ * `nextCursor` until it's absent.
61
+ */
62
+ list(tenantId: TenantId, filter: OrgListFilter): Promise<Page<Org>>;
63
+ /**
64
+ * Partially update an `Org`. Fields absent from `patch` are left
65
+ * unchanged; fields present are set to the new value. Not-found is
66
+ * an error the adapter surfaces (e.g. as a rejected promise); the
67
+ * conformance suite specifies the exact shape.
68
+ */
69
+ update(tenantId: TenantId, orgId: OrgId, patch: OrgPatch): Promise<void>;
70
+ /**
71
+ * Delete an `Org`. Cascade semantics (dependent teams / projects) are
72
+ * a storage-layer concern — the in-memory adapter does the delete
73
+ * unconditionally.
74
+ */
75
+ delete(tenantId: TenantId, orgId: OrgId): Promise<void>;
76
+ }
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `ProjectBinding` + `ProjectMembershipBinding` — the project CRUD
6
+ * surface + the user↔project direct-grant membership surface within a
7
+ * tenant.
8
+ *
9
+ * Projects are the primary content-scope; all content (memory /
10
+ * agents / flows / guardrails / runs / artifacts / cost / evals /
11
+ * approvals / evidence / provenance / observations) carries a
12
+ * required `projectId` and is read/written filtered by it.
13
+ *
14
+ * `getDefault` returns the tenant's auto-created "Default" project.
15
+ * That row is the only one where `Project.isDefault = true`;
16
+ * implementations keep exactly one per tenant (this binding's
17
+ * in-memory adapter checks at write time, before the row lands in the
18
+ * map). Callers that don't naturally know a project resolve the
19
+ * Default with `getDefault` and pass its id explicitly (see e.g.
20
+ * `AgentPublishInput.projectId` in `@kindgi/api`).
21
+ *
22
+ * Same convention as `org-binding.ts`: `tenantId` first, `Page<T>`
23
+ * return, `Filter`-extending filter shapes.
24
+ */
25
+
26
+ import type { Filter, OrgId, Page, ProjectId, TenantId, UserId } from '@kindgi/types';
27
+
28
+ import type {
29
+ Project,
30
+ ProjectMembership,
31
+ ProjectPatch,
32
+ ProjectRole,
33
+ ProjectSpec,
34
+ } from './types.js';
35
+
36
+ /**
37
+ * `list` filter for projects. Same shape as `TeamListFilter` — `orgId`
38
+ * narrows to a specific org's projects (nullable FK);
39
+ * `nameContains` is a substring match on `Project.name`.
40
+ */
41
+ export interface ProjectListFilter extends Filter {
42
+ readonly orgId?: OrgId;
43
+ readonly nameContains?: string;
44
+ }
45
+
46
+ /**
47
+ * Input for `ProjectMembershipBinding.add`. Distinct interface so
48
+ * downstream additions extend without breaking call sites.
49
+ */
50
+ export interface ProjectMembershipAddInput {
51
+ readonly projectId: ProjectId;
52
+ readonly userId: UserId;
53
+ readonly role: ProjectRole;
54
+ }
55
+
56
+ /**
57
+ * The project CRUD binding.
58
+ */
59
+ export interface ProjectBinding {
60
+ /**
61
+ * Create a `Project` within the tenant. `spec.orgId` optional. If
62
+ * `spec.isDefault === true` and a Default already exists, the
63
+ * binding rejects — the isDefault-uniqueness guardrail is enforced
64
+ * at write time.
65
+ */
66
+ create(tenantId: TenantId, spec: ProjectSpec): Promise<ProjectId>;
67
+ /**
68
+ * Look up a `Project` by id. `undefined` when unknown or in a
69
+ * different tenant.
70
+ */
71
+ get(tenantId: TenantId, projectId: ProjectId): Promise<Project | undefined>;
72
+ /**
73
+ * Cursor-paginated list of projects. Filter by `orgId` or
74
+ * `nameContains`.
75
+ */
76
+ list(tenantId: TenantId, filter: ProjectListFilter): Promise<Page<Project>>;
77
+ /**
78
+ * The tenant's Default project (`isDefault: true`). Returns
79
+ * `undefined` when no Default project exists for the tenant.
80
+ */
81
+ getDefault(tenantId: TenantId): Promise<Project | undefined>;
82
+ /**
83
+ * Partially update a `Project`. `ProjectPatch.orgId` supports
84
+ * `null` to re-assign the project out of any org.
85
+ */
86
+ update(tenantId: TenantId, projectId: ProjectId, patch: ProjectPatch): Promise<void>;
87
+ /**
88
+ * Delete a `Project`. What happens to content rows that still
89
+ * reference the project is the storage layer's concern; the
90
+ * in-memory adapter removes the project's direct memberships and
91
+ * team grants with it.
92
+ */
93
+ delete(tenantId: TenantId, projectId: ProjectId): Promise<void>;
94
+ }
95
+
96
+ /**
97
+ * User↔project direct-grant membership. Additive with team grants
98
+ * (the union rules); a user may have both a direct
99
+ * `ProjectMembership` and access via a team-project grant.
100
+ * Authorization resolves the union.
101
+ */
102
+ export interface ProjectMembershipBinding {
103
+ /**
104
+ * Add a user directly to a project. Idempotent on
105
+ * `(projectId, userId)` — re-adding the same user does not mutate
106
+ * a stored differing role. Use `updateRole` to change roles.
107
+ */
108
+ add(tenantId: TenantId, input: ProjectMembershipAddInput): Promise<void>;
109
+ /**
110
+ * Remove the direct project grant. No-op when absent.
111
+ */
112
+ remove(tenantId: TenantId, projectId: ProjectId, userId: UserId): Promise<void>;
113
+ /**
114
+ * Cursor-paginated direct memberships of a project. Ordered by
115
+ * `joinedAt` ascending.
116
+ */
117
+ list(tenantId: TenantId, projectId: ProjectId, filter: Filter): Promise<Page<ProjectMembership>>;
118
+ /**
119
+ * Cursor-paginated direct memberships of a user (across every
120
+ * project the user is directly a member of within the tenant).
121
+ * Used to resolve a user's direct project set.
122
+ */
123
+ listForUser(tenantId: TenantId, userId: UserId, filter: Filter): Promise<Page<ProjectMembership>>;
124
+ /**
125
+ * Update an existing direct-membership's role. Errors when the
126
+ * membership doesn't exist.
127
+ */
128
+ updateRole(
129
+ tenantId: TenantId,
130
+ projectId: ProjectId,
131
+ userId: UserId,
132
+ role: ProjectRole,
133
+ ): Promise<void>;
134
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The `Scope` primitive — a required, discriminated access boundary.
6
+ *
7
+ * Design decision: a subset of framework resources —
8
+ * those that describe policy, configuration, or authoring templates
9
+ * rather than run-time content — carry a required, discriminated
10
+ * `Scope` value that names the level at which the resource is
11
+ * visible. The `Scope` is not a filter; it's a **row-level property**
12
+ * recorded at insert time.
13
+ *
14
+ * Which resources carry `Scope` (vs `projectId`)?
15
+ * - **Content-scoped** (agents, flows, tools, guardrails, memory
16
+ * facts, artifacts, runs, cost records, eval suites, eval runs,
17
+ * approvals, evidence, provenance, observations): a required
18
+ * `projectId`. Rows are the *contents* of a project.
19
+ * - **Policy/config-scoped** (MCP endpoints, secrets, env values,
20
+ * pack templates, S3 credentials): `Scope` discriminated
21
+ * (Tenant / Org / Project). Describes *how* content is served —
22
+ * auth providers, credentials, template catalogs. A firm may want
23
+ * firm-wide MCP endpoints OR matter-scoped MCP endpoints; fixed to
24
+ * one level is wrong, nullable is worse.
25
+ * - **Tenant-only** (adapters, capabilities, identity providers,
26
+ * deployment ledger, signing keys, tenant config): a required
27
+ * `tenantId` only. Deployment-wide framework configuration.
28
+ *
29
+ * Resolution semantics (most-specific-wins): for a resource lookup
30
+ * within a request context that carries `{ tenantId, projectId }`,
31
+ * walk `project → org → tenant` (the org is the project's `orgId`);
32
+ * first match wins.
33
+ */
34
+
35
+ import type { OrgId, ProjectId, TenantId } from '@kindgi/types';
36
+
37
+ export type Scope =
38
+ | { readonly kind: 'tenant'; readonly tenantId: TenantId }
39
+ | { readonly kind: 'org'; readonly tenantId: TenantId; readonly orgId: OrgId }
40
+ | { readonly kind: 'project'; readonly tenantId: TenantId; readonly projectId: ProjectId };
41
+
42
+ export function scopeKey(s: Scope): string {
43
+ switch (s.kind) {
44
+ case 'tenant':
45
+ return `tenant:${s.tenantId}`;
46
+ case 'org':
47
+ return `org:${s.orgId}`;
48
+ case 'project':
49
+ return `project:${s.projectId}`;
50
+ }
51
+ }