@kindgi/platform 0.0.0-bootstrap.0 → 0.1.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/LICENSE +201 -0
- package/README.md +41 -1
- package/dist/in-memory/index.d.ts +9 -0
- package/dist/in-memory/index.d.ts.map +1 -0
- package/dist/in-memory/index.js +11 -0
- package/dist/in-memory/index.js.map +1 -0
- package/dist/in-memory/org-binding.d.ts +6 -0
- package/dist/in-memory/org-binding.d.ts.map +1 -0
- package/dist/in-memory/org-binding.js +81 -0
- package/dist/in-memory/org-binding.js.map +1 -0
- package/dist/in-memory/project-binding.d.ts +13 -0
- package/dist/in-memory/project-binding.d.ts.map +1 -0
- package/dist/in-memory/project-binding.js +286 -0
- package/dist/in-memory/project-binding.js.map +1 -0
- package/dist/in-memory/team-binding.d.ts +12 -0
- package/dist/in-memory/team-binding.d.ts.map +1 -0
- package/dist/in-memory/team-binding.js +170 -0
- package/dist/in-memory/team-binding.js.map +1 -0
- package/dist/in-memory/util.d.ts +27 -0
- package/dist/in-memory/util.d.ts.map +1 -0
- package/dist/in-memory/util.js +49 -0
- package/dist/in-memory/util.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/org-binding.d.ts +70 -0
- package/dist/org-binding.d.ts.map +1 -0
- package/dist/org-binding.js +4 -0
- package/dist/org-binding.js.map +1 -0
- package/dist/project-binding.d.ts +115 -0
- package/dist/project-binding.d.ts.map +1 -0
- package/dist/project-binding.js +4 -0
- package/dist/project-binding.js.map +1 -0
- package/dist/scope.d.ts +45 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +13 -0
- package/dist/scope.js.map +1 -0
- package/dist/team-binding.d.ts +108 -0
- package/dist/team-binding.d.ts.map +1 -0
- package/dist/team-binding.js +4 -0
- package/dist/team-binding.js.map +1 -0
- package/dist/team-project-grant-binding.d.ts +49 -0
- package/dist/team-project-grant-binding.d.ts.map +1 -0
- package/dist/team-project-grant-binding.js +4 -0
- package/dist/team-project-grant-binding.js.map +1 -0
- package/dist/tenant-hierarchy-binding.d.ts +104 -0
- package/dist/tenant-hierarchy-binding.d.ts.map +1 -0
- package/dist/tenant-hierarchy-binding.js +4 -0
- package/dist/tenant-hierarchy-binding.js.map +1 -0
- package/dist/types.d.ts +129 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +49 -4
- package/src/in-memory/index.ts +12 -0
- package/src/in-memory/org-binding.ts +101 -0
- package/src/in-memory/project-binding.ts +327 -0
- package/src/in-memory/team-binding.ts +205 -0
- package/src/in-memory/util.ts +72 -0
- package/src/index.ts +56 -0
- package/src/org-binding.ts +76 -0
- package/src/project-binding.ts +134 -0
- package/src/scope.ts +51 -0
- package/src/team-binding.ts +116 -0
- package/src/team-project-grant-binding.ts +65 -0
- package/src/tenant-hierarchy-binding.ts +107 -0
- package/src/types.ts +177 -0
- package/tests/in-memory-org-binding.test.ts +12 -0
- package/tests/in-memory-project-binding.test.ts +12 -0
- package/tests/in-memory-team-binding.test.ts +12 -0
- package/tests/org-binding.conformance.ts +165 -0
- package/tests/project-binding.conformance.ts +231 -0
- 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
|
+
}
|