@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
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-tenant hierarchy primitives — the type surface for the
|
|
3
|
+
* `Tenant → Org → Team → Project + User` flow.
|
|
4
|
+
*
|
|
5
|
+
* **Pure type shapes.** No runtime code, no validation, no defaults.
|
|
6
|
+
* Binding interfaces live in the sibling `*-binding.ts` files;
|
|
7
|
+
* reference in-memory adapters live under `in-memory/`.
|
|
8
|
+
*
|
|
9
|
+
* Branded IDs are imported from `@kindgi/types` — this package
|
|
10
|
+
* adds no new IDs. `Tenant` itself has no full shape here (only the
|
|
11
|
+
* read-only `TenantSummary` in `tenant-hierarchy-binding.ts`); this
|
|
12
|
+
* package models the hierarchy that sits *under* the tenant.
|
|
13
|
+
*/
|
|
14
|
+
import type { OrgId, ProjectId, TeamId, TenantId, Timestamp, UserId } from '@kindgi/types';
|
|
15
|
+
export interface Org {
|
|
16
|
+
readonly id: OrgId;
|
|
17
|
+
readonly tenantId: TenantId;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly slug: string;
|
|
20
|
+
readonly createdAt: Timestamp;
|
|
21
|
+
readonly updatedAt: Timestamp;
|
|
22
|
+
}
|
|
23
|
+
export interface OrgSpec {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly slug: string;
|
|
26
|
+
}
|
|
27
|
+
export interface OrgPatch {
|
|
28
|
+
readonly name?: string;
|
|
29
|
+
readonly slug?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface Team {
|
|
32
|
+
readonly id: TeamId;
|
|
33
|
+
readonly tenantId: TenantId;
|
|
34
|
+
/**
|
|
35
|
+
* Optional — a team may belong to an org, or be cross-org (no
|
|
36
|
+
* `orgId`).
|
|
37
|
+
*/
|
|
38
|
+
readonly orgId?: OrgId;
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly slug: string;
|
|
41
|
+
readonly description?: string;
|
|
42
|
+
readonly createdAt: Timestamp;
|
|
43
|
+
readonly updatedAt: Timestamp;
|
|
44
|
+
}
|
|
45
|
+
export interface TeamSpec {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly slug: string;
|
|
48
|
+
readonly description?: string;
|
|
49
|
+
readonly orgId?: OrgId;
|
|
50
|
+
}
|
|
51
|
+
export interface TeamPatch {
|
|
52
|
+
readonly name?: string;
|
|
53
|
+
readonly slug?: string;
|
|
54
|
+
readonly description?: string;
|
|
55
|
+
/**
|
|
56
|
+
* `null` explicitly re-assigns the team out of any org (cross-org).
|
|
57
|
+
* `undefined` leaves the FK untouched.
|
|
58
|
+
*/
|
|
59
|
+
readonly orgId?: OrgId | null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Roles on a `TeamMembership`. Aligned with org/project vocabulary:
|
|
63
|
+
* `admin` + `member`. Additive with project-level grants: a user in
|
|
64
|
+
* three teams has the union of the three teams' project grants.
|
|
65
|
+
* Framework-owned — no external directory sync.
|
|
66
|
+
*/
|
|
67
|
+
export type TeamRole = 'member' | 'admin';
|
|
68
|
+
export interface TeamMembership {
|
|
69
|
+
readonly teamId: TeamId;
|
|
70
|
+
readonly userId: UserId;
|
|
71
|
+
readonly role: TeamRole;
|
|
72
|
+
readonly joinedAt: Timestamp;
|
|
73
|
+
}
|
|
74
|
+
export interface Project {
|
|
75
|
+
readonly id: ProjectId;
|
|
76
|
+
readonly tenantId: TenantId;
|
|
77
|
+
/**
|
|
78
|
+
* Optional — a project may belong to an org, or be cross-org (no
|
|
79
|
+
* `orgId`).
|
|
80
|
+
*/
|
|
81
|
+
readonly orgId?: OrgId;
|
|
82
|
+
readonly name: string;
|
|
83
|
+
readonly slug: string;
|
|
84
|
+
/**
|
|
85
|
+
* `true` for the tenant's auto-created "Default" project. Exactly
|
|
86
|
+
* one row per tenant carries `isDefault = true`.
|
|
87
|
+
* `false` for every user-created project.
|
|
88
|
+
*/
|
|
89
|
+
readonly isDefault: boolean;
|
|
90
|
+
readonly description?: string;
|
|
91
|
+
readonly createdAt: Timestamp;
|
|
92
|
+
readonly updatedAt: Timestamp;
|
|
93
|
+
}
|
|
94
|
+
export interface ProjectSpec {
|
|
95
|
+
readonly name: string;
|
|
96
|
+
readonly slug: string;
|
|
97
|
+
readonly description?: string;
|
|
98
|
+
readonly orgId?: OrgId;
|
|
99
|
+
/**
|
|
100
|
+
* Optional at spec time — the tenant's Default project is created
|
|
101
|
+
* by the framework at tenant-inception, not by a caller. User
|
|
102
|
+
* projects created via `POST /v1/projects` default to `false`.
|
|
103
|
+
*/
|
|
104
|
+
readonly isDefault?: boolean;
|
|
105
|
+
}
|
|
106
|
+
export interface ProjectPatch {
|
|
107
|
+
readonly name?: string;
|
|
108
|
+
readonly slug?: string;
|
|
109
|
+
readonly description?: string;
|
|
110
|
+
/**
|
|
111
|
+
* `null` explicitly re-assigns the project out of any org.
|
|
112
|
+
* `undefined` leaves the FK untouched.
|
|
113
|
+
*/
|
|
114
|
+
readonly orgId?: OrgId | null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Roles on a `ProjectMembership` — the direct `User → Project` grant
|
|
118
|
+
* shape. Additive with team-grants. Mirrors the OpenFGA project-type
|
|
119
|
+
* relations (owner / editor / viewer) plus the
|
|
120
|
+
* lightweight `member` and administrative `admin` shorthands.
|
|
121
|
+
*/
|
|
122
|
+
export type ProjectRole = 'viewer' | 'editor' | 'owner' | 'admin' | 'member';
|
|
123
|
+
export interface ProjectMembership {
|
|
124
|
+
readonly projectId: ProjectId;
|
|
125
|
+
readonly userId: UserId;
|
|
126
|
+
readonly role: ProjectRole;
|
|
127
|
+
readonly joinedAt: Timestamp;
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,iCAAiC;;AA8KjC,sEAAsE;AACtE,mBAAmB"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kindgi/platform",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multi-tenant hierarchy primitives — the Tenant → Org → Team → Project + User type surface, the `Scope` discriminated access-boundary, binding interfaces for orgs, teams, projects, memberships, team-project grants and the tenant hierarchy, and reference in-memory adapters.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"repository": {
|
|
7
|
-
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/kindgi/kindgi-sdk.git",
|
|
9
|
+
"directory": "packages/platform"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/kindgi/kindgi-sdk/tree/main/packages/platform#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/kindgi/kindgi-sdk/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./tests/*": "./tests/*"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"tests",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@kindgi/types": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.10.5",
|
|
36
|
+
"typescript": "^5.7.3",
|
|
37
|
+
"vitest": "^2.1.8"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.0.0"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"provenance": true
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.build.json",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Barrel export for the reference in-memory adapters. Consumers wire
|
|
6
|
+
* these into dev-time defaults + tests; production deployments supply
|
|
7
|
+
* durable implementations of the same binding interfaces.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { makeInMemoryOrgBinding } from './org-binding.js';
|
|
11
|
+
export { makeInMemoryTeamBinding } from './team-binding.js';
|
|
12
|
+
export { makeInMemoryProjectBinding } from './project-binding.js';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `makeInMemoryOrgBinding` — reference in-memory implementation of
|
|
6
|
+
* `OrgBinding`. Used by tests + dev-time defaults. Not durable —
|
|
7
|
+
* production deployments supply their own implementation.
|
|
8
|
+
*
|
|
9
|
+
* Storage: a single `Map<OrgId, Org>` keyed by orgId. Every operation
|
|
10
|
+
* filters by `tenantId` at the row level — a lookup from tenant A
|
|
11
|
+
* against an orgId that exists in tenant B returns `undefined` (not a
|
|
12
|
+
* hit). Cross-tenant leakage is a hard guardrail, asserted by the
|
|
13
|
+
* conformance suite.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { OrgId, Page, TenantId, Timestamp } from '@kindgi/types';
|
|
17
|
+
|
|
18
|
+
import type { OrgBinding, OrgListFilter } from '../org-binding.js';
|
|
19
|
+
import type { Org, OrgPatch, OrgSpec } from '../types.js';
|
|
20
|
+
|
|
21
|
+
import { nowTimestamp, paginate } from './util.js';
|
|
22
|
+
|
|
23
|
+
let orgIdCounter = 0;
|
|
24
|
+
function nextOrgId(): OrgId {
|
|
25
|
+
orgIdCounter += 1;
|
|
26
|
+
return `org-${orgIdCounter}` as OrgId;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Factory for the in-memory `OrgBinding`.
|
|
31
|
+
*/
|
|
32
|
+
export function makeInMemoryOrgBinding(): OrgBinding {
|
|
33
|
+
const rows = new Map<OrgId, Org>();
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
async create(tenantId: TenantId, spec: OrgSpec): Promise<OrgId> {
|
|
37
|
+
const id = nextOrgId();
|
|
38
|
+
const now = nowTimestamp();
|
|
39
|
+
const row: Org = {
|
|
40
|
+
id,
|
|
41
|
+
tenantId,
|
|
42
|
+
name: spec.name,
|
|
43
|
+
slug: spec.slug,
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
};
|
|
47
|
+
rows.set(id, row);
|
|
48
|
+
return id;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async get(tenantId: TenantId, orgId: OrgId): Promise<Org | undefined> {
|
|
52
|
+
const row = rows.get(orgId);
|
|
53
|
+
if (row === undefined) return undefined;
|
|
54
|
+
if (row.tenantId !== tenantId) return undefined;
|
|
55
|
+
return row;
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async list(tenantId: TenantId, filter: OrgListFilter): Promise<Page<Org>> {
|
|
59
|
+
const all: Org[] = [];
|
|
60
|
+
for (const row of rows.values()) {
|
|
61
|
+
if (row.tenantId !== tenantId) continue;
|
|
62
|
+
if (filter.nameContains !== undefined && !row.name.includes(filter.nameContains)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
all.push(row);
|
|
66
|
+
}
|
|
67
|
+
// Deterministic ordering by createdAt then id for stable pagination.
|
|
68
|
+
all.sort((a, b) => {
|
|
69
|
+
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
|
|
70
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
71
|
+
});
|
|
72
|
+
return paginate(all, filter.limit, filter.cursor);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async update(tenantId: TenantId, orgId: OrgId, patch: OrgPatch): Promise<void> {
|
|
76
|
+
const row = rows.get(orgId);
|
|
77
|
+
if (row === undefined || row.tenantId !== tenantId) {
|
|
78
|
+
throw new Error(`org-not-found: ${orgId}`);
|
|
79
|
+
}
|
|
80
|
+
const now: Timestamp = nowTimestamp();
|
|
81
|
+
const next: Org = {
|
|
82
|
+
id: row.id,
|
|
83
|
+
tenantId: row.tenantId,
|
|
84
|
+
name: patch.name ?? row.name,
|
|
85
|
+
slug: patch.slug ?? row.slug,
|
|
86
|
+
createdAt: row.createdAt,
|
|
87
|
+
updatedAt: now,
|
|
88
|
+
};
|
|
89
|
+
rows.set(orgId, next);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async delete(tenantId: TenantId, orgId: OrgId): Promise<void> {
|
|
93
|
+
const row = rows.get(orgId);
|
|
94
|
+
if (row === undefined || row.tenantId !== tenantId) {
|
|
95
|
+
// Idempotent delete — no-op when absent or in another tenant.
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
rows.delete(orgId);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `makeInMemoryProjectBinding` — reference in-memory implementations of
|
|
6
|
+
* `ProjectBinding` + `ProjectMembershipBinding` + `TeamProjectGrantBinding`.
|
|
7
|
+
*
|
|
8
|
+
* Storage: three maps — projects, direct-grant memberships, team-project
|
|
9
|
+
* grants. All share the same tenant-isolation guardrail + return the
|
|
10
|
+
* combined trio so callers wire the whole surface at once. The
|
|
11
|
+
* in-memory adapter keeps the team↔project join (`TeamProjectGrant`)
|
|
12
|
+
* here for simplicity.
|
|
13
|
+
*
|
|
14
|
+
* `create` enforces the `Project.isDefault = true` uniqueness
|
|
15
|
+
* guardrail at write time: creating a second Default in the same
|
|
16
|
+
* tenant rejects.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Filter, Page, ProjectId, TeamId, TenantId, UserId } from '@kindgi/types';
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
ProjectBinding,
|
|
23
|
+
ProjectListFilter,
|
|
24
|
+
ProjectMembershipAddInput,
|
|
25
|
+
ProjectMembershipBinding,
|
|
26
|
+
} from '../project-binding.js';
|
|
27
|
+
import type {
|
|
28
|
+
TeamProjectGrant,
|
|
29
|
+
TeamProjectGrantAddInput,
|
|
30
|
+
TeamProjectGrantBinding,
|
|
31
|
+
} from '../team-project-grant-binding.js';
|
|
32
|
+
import type {
|
|
33
|
+
Project,
|
|
34
|
+
ProjectMembership,
|
|
35
|
+
ProjectPatch,
|
|
36
|
+
ProjectRole,
|
|
37
|
+
ProjectSpec,
|
|
38
|
+
} from '../types.js';
|
|
39
|
+
|
|
40
|
+
import { nowTimestamp, paginate } from './util.js';
|
|
41
|
+
|
|
42
|
+
let projectIdCounter = 0;
|
|
43
|
+
function nextProjectId(): ProjectId {
|
|
44
|
+
projectIdCounter += 1;
|
|
45
|
+
return `project-${projectIdCounter}` as ProjectId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function projMembershipKey(projectId: ProjectId, userId: UserId): string {
|
|
49
|
+
return `${projectId}::${userId}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function grantKey(teamId: TeamId, projectId: ProjectId): string {
|
|
53
|
+
return `${teamId}::${projectId}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Combined factory — returns the three project-related bindings that
|
|
58
|
+
* share underlying storage. Any of them can be plucked out and passed
|
|
59
|
+
* individually to consumers that need just one.
|
|
60
|
+
*/
|
|
61
|
+
export function makeInMemoryProjectBinding(): {
|
|
62
|
+
readonly projects: ProjectBinding;
|
|
63
|
+
readonly memberships: ProjectMembershipBinding;
|
|
64
|
+
readonly grants: TeamProjectGrantBinding;
|
|
65
|
+
} {
|
|
66
|
+
const projectRows = new Map<ProjectId, Project>();
|
|
67
|
+
const membershipRows = new Map<string, ProjectMembership>();
|
|
68
|
+
const grantRows = new Map<string, TeamProjectGrant>();
|
|
69
|
+
// Track grant insertion order for deterministic `list` ordering.
|
|
70
|
+
const grantOrder: string[] = [];
|
|
71
|
+
|
|
72
|
+
function findProjectInTenant(tenantId: TenantId, projectId: ProjectId): Project | undefined {
|
|
73
|
+
const p = projectRows.get(projectId);
|
|
74
|
+
if (p === undefined || p.tenantId !== tenantId) return undefined;
|
|
75
|
+
return p;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function tenantAlreadyHasDefault(tenantId: TenantId): boolean {
|
|
79
|
+
for (const row of projectRows.values()) {
|
|
80
|
+
if (row.tenantId === tenantId && row.isDefault) return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const projects: ProjectBinding = {
|
|
86
|
+
async create(tenantId, spec: ProjectSpec): Promise<ProjectId> {
|
|
87
|
+
const isDefault = spec.isDefault === true;
|
|
88
|
+
if (isDefault && tenantAlreadyHasDefault(tenantId)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`project-default-already-exists: tenant ${tenantId} already has an isDefault=true project`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const id = nextProjectId();
|
|
94
|
+
const now = nowTimestamp();
|
|
95
|
+
const row: Project = {
|
|
96
|
+
id,
|
|
97
|
+
tenantId,
|
|
98
|
+
name: spec.name,
|
|
99
|
+
slug: spec.slug,
|
|
100
|
+
isDefault,
|
|
101
|
+
createdAt: now,
|
|
102
|
+
updatedAt: now,
|
|
103
|
+
...(spec.orgId !== undefined ? { orgId: spec.orgId } : {}),
|
|
104
|
+
...(spec.description !== undefined ? { description: spec.description } : {}),
|
|
105
|
+
};
|
|
106
|
+
projectRows.set(id, row);
|
|
107
|
+
return id;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async get(tenantId, projectId): Promise<Project | undefined> {
|
|
111
|
+
return findProjectInTenant(tenantId, projectId);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async list(tenantId, filter: ProjectListFilter): Promise<Page<Project>> {
|
|
115
|
+
const all: Project[] = [];
|
|
116
|
+
for (const row of projectRows.values()) {
|
|
117
|
+
if (row.tenantId !== tenantId) continue;
|
|
118
|
+
if (filter.orgId !== undefined && row.orgId !== filter.orgId) continue;
|
|
119
|
+
if (filter.nameContains !== undefined && !row.name.includes(filter.nameContains)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
all.push(row);
|
|
123
|
+
}
|
|
124
|
+
all.sort((a, b) => {
|
|
125
|
+
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
|
|
126
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
127
|
+
});
|
|
128
|
+
return paginate(all, filter.limit, filter.cursor);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async getDefault(tenantId): Promise<Project | undefined> {
|
|
132
|
+
for (const row of projectRows.values()) {
|
|
133
|
+
if (row.tenantId === tenantId && row.isDefault) return row;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async update(tenantId, projectId, patch: ProjectPatch): Promise<void> {
|
|
139
|
+
const row = findProjectInTenant(tenantId, projectId);
|
|
140
|
+
if (row === undefined) {
|
|
141
|
+
throw new Error(`project-not-found: ${projectId}`);
|
|
142
|
+
}
|
|
143
|
+
let nextOrgId = row.orgId;
|
|
144
|
+
if (patch.orgId !== undefined) {
|
|
145
|
+
nextOrgId = patch.orgId === null ? undefined : patch.orgId;
|
|
146
|
+
}
|
|
147
|
+
const nextDescription = patch.description ?? row.description;
|
|
148
|
+
const next: Project = {
|
|
149
|
+
id: row.id,
|
|
150
|
+
tenantId: row.tenantId,
|
|
151
|
+
name: patch.name ?? row.name,
|
|
152
|
+
slug: patch.slug ?? row.slug,
|
|
153
|
+
isDefault: row.isDefault,
|
|
154
|
+
createdAt: row.createdAt,
|
|
155
|
+
updatedAt: nowTimestamp(),
|
|
156
|
+
...(nextOrgId !== undefined ? { orgId: nextOrgId } : {}),
|
|
157
|
+
...(nextDescription !== undefined ? { description: nextDescription } : {}),
|
|
158
|
+
};
|
|
159
|
+
projectRows.set(projectId, next);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async delete(tenantId, projectId): Promise<void> {
|
|
163
|
+
const row = findProjectInTenant(tenantId, projectId);
|
|
164
|
+
if (row === undefined) return;
|
|
165
|
+
projectRows.delete(projectId);
|
|
166
|
+
// Cascade — direct memberships + team grants drop with the project.
|
|
167
|
+
for (const key of membershipRows.keys()) {
|
|
168
|
+
if (key.startsWith(`${projectId}::`)) {
|
|
169
|
+
membershipRows.delete(key);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (let i = grantOrder.length - 1; i >= 0; i -= 1) {
|
|
173
|
+
const key = grantOrder[i];
|
|
174
|
+
if (key === undefined) continue;
|
|
175
|
+
if (key.endsWith(`::${projectId}`)) {
|
|
176
|
+
grantRows.delete(key);
|
|
177
|
+
grantOrder.splice(i, 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const memberships: ProjectMembershipBinding = {
|
|
184
|
+
async add(tenantId, input: ProjectMembershipAddInput): Promise<void> {
|
|
185
|
+
const proj = findProjectInTenant(tenantId, input.projectId);
|
|
186
|
+
if (proj === undefined) {
|
|
187
|
+
throw new Error(`project-not-found: ${input.projectId}`);
|
|
188
|
+
}
|
|
189
|
+
const key = projMembershipKey(input.projectId, input.userId);
|
|
190
|
+
if (membershipRows.has(key)) return;
|
|
191
|
+
const row: ProjectMembership = {
|
|
192
|
+
projectId: input.projectId,
|
|
193
|
+
userId: input.userId,
|
|
194
|
+
role: input.role,
|
|
195
|
+
joinedAt: nowTimestamp(),
|
|
196
|
+
};
|
|
197
|
+
membershipRows.set(key, row);
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
async remove(tenantId, projectId, userId): Promise<void> {
|
|
201
|
+
const proj = findProjectInTenant(tenantId, projectId);
|
|
202
|
+
if (proj === undefined) return;
|
|
203
|
+
membershipRows.delete(projMembershipKey(projectId, userId));
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async list(tenantId, projectId, filter: Filter): Promise<Page<ProjectMembership>> {
|
|
207
|
+
const proj = findProjectInTenant(tenantId, projectId);
|
|
208
|
+
if (proj === undefined) return { items: [] };
|
|
209
|
+
const all: ProjectMembership[] = [];
|
|
210
|
+
for (const row of membershipRows.values()) {
|
|
211
|
+
if (row.projectId !== projectId) continue;
|
|
212
|
+
all.push(row);
|
|
213
|
+
}
|
|
214
|
+
all.sort((a, b) => (a.joinedAt < b.joinedAt ? -1 : a.joinedAt > b.joinedAt ? 1 : 0));
|
|
215
|
+
return paginate(all, filter.limit, filter.cursor);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async listForUser(tenantId, userId, filter: Filter): Promise<Page<ProjectMembership>> {
|
|
219
|
+
const all: ProjectMembership[] = [];
|
|
220
|
+
for (const row of membershipRows.values()) {
|
|
221
|
+
if (row.userId !== userId) continue;
|
|
222
|
+
const p = projectRows.get(row.projectId);
|
|
223
|
+
if (p === undefined || p.tenantId !== tenantId) continue;
|
|
224
|
+
all.push(row);
|
|
225
|
+
}
|
|
226
|
+
all.sort((a, b) => (a.joinedAt < b.joinedAt ? -1 : a.joinedAt > b.joinedAt ? 1 : 0));
|
|
227
|
+
return paginate(all, filter.limit, filter.cursor);
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async updateRole(tenantId, projectId, userId, role: ProjectRole): Promise<void> {
|
|
231
|
+
const proj = findProjectInTenant(tenantId, projectId);
|
|
232
|
+
if (proj === undefined) {
|
|
233
|
+
throw new Error(`project-not-found: ${projectId}`);
|
|
234
|
+
}
|
|
235
|
+
const key = projMembershipKey(projectId, userId);
|
|
236
|
+
const row = membershipRows.get(key);
|
|
237
|
+
if (row === undefined) {
|
|
238
|
+
throw new Error(`project-membership-not-found: ${projectId}/${userId}`);
|
|
239
|
+
}
|
|
240
|
+
membershipRows.set(key, { ...row, role });
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const grants: TeamProjectGrantBinding = {
|
|
245
|
+
async add(tenantId, input: TeamProjectGrantAddInput): Promise<void> {
|
|
246
|
+
// Grant validity requires the project to exist in-tenant. Team
|
|
247
|
+
// existence is not checked here: teams live in the separate
|
|
248
|
+
// `makeInMemoryTeamBinding` store.
|
|
249
|
+
const proj = findProjectInTenant(tenantId, input.projectId);
|
|
250
|
+
if (proj === undefined) {
|
|
251
|
+
throw new Error(`project-not-found: ${input.projectId}`);
|
|
252
|
+
}
|
|
253
|
+
const key = grantKey(input.teamId, input.projectId);
|
|
254
|
+
if (grantRows.has(key)) return;
|
|
255
|
+
const row: TeamProjectGrant = {
|
|
256
|
+
teamId: input.teamId,
|
|
257
|
+
projectId: input.projectId,
|
|
258
|
+
role: input.role,
|
|
259
|
+
};
|
|
260
|
+
grantRows.set(key, row);
|
|
261
|
+
grantOrder.push(key);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async remove(tenantId, teamId, projectId): Promise<void> {
|
|
265
|
+
const proj = findProjectInTenant(tenantId, projectId);
|
|
266
|
+
if (proj === undefined) return;
|
|
267
|
+
const key = grantKey(teamId, projectId);
|
|
268
|
+
if (grantRows.delete(key)) {
|
|
269
|
+
const idx = grantOrder.indexOf(key);
|
|
270
|
+
if (idx >= 0) grantOrder.splice(idx, 1);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async list(tenantId, filter: Filter): Promise<Page<TeamProjectGrant>> {
|
|
275
|
+
const all: TeamProjectGrant[] = [];
|
|
276
|
+
for (const key of grantOrder) {
|
|
277
|
+
const row = grantRows.get(key);
|
|
278
|
+
if (row === undefined) continue;
|
|
279
|
+
const p = projectRows.get(row.projectId);
|
|
280
|
+
if (p === undefined || p.tenantId !== tenantId) continue;
|
|
281
|
+
all.push(row);
|
|
282
|
+
}
|
|
283
|
+
return paginate(all, filter.limit, filter.cursor);
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
async listForTeam(tenantId, teamId, filter: Filter): Promise<Page<TeamProjectGrant>> {
|
|
287
|
+
const all: TeamProjectGrant[] = [];
|
|
288
|
+
for (const key of grantOrder) {
|
|
289
|
+
const row = grantRows.get(key);
|
|
290
|
+
if (row === undefined) continue;
|
|
291
|
+
if (row.teamId !== teamId) continue;
|
|
292
|
+
const p = projectRows.get(row.projectId);
|
|
293
|
+
if (p === undefined || p.tenantId !== tenantId) continue;
|
|
294
|
+
all.push(row);
|
|
295
|
+
}
|
|
296
|
+
return paginate(all, filter.limit, filter.cursor);
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
async listForProject(tenantId, projectId, filter: Filter): Promise<Page<TeamProjectGrant>> {
|
|
300
|
+
const proj = findProjectInTenant(tenantId, projectId);
|
|
301
|
+
if (proj === undefined) return { items: [] };
|
|
302
|
+
const all: TeamProjectGrant[] = [];
|
|
303
|
+
for (const key of grantOrder) {
|
|
304
|
+
const row = grantRows.get(key);
|
|
305
|
+
if (row === undefined) continue;
|
|
306
|
+
if (row.projectId !== projectId) continue;
|
|
307
|
+
all.push(row);
|
|
308
|
+
}
|
|
309
|
+
return paginate(all, filter.limit, filter.cursor);
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async updateRole(tenantId, teamId, projectId, role: ProjectRole): Promise<void> {
|
|
313
|
+
const proj = findProjectInTenant(tenantId, projectId);
|
|
314
|
+
if (proj === undefined) {
|
|
315
|
+
throw new Error(`project-not-found: ${projectId}`);
|
|
316
|
+
}
|
|
317
|
+
const key = grantKey(teamId, projectId);
|
|
318
|
+
const row = grantRows.get(key);
|
|
319
|
+
if (row === undefined) {
|
|
320
|
+
throw new Error(`team-project-grant-not-found: ${teamId}/${projectId}`);
|
|
321
|
+
}
|
|
322
|
+
grantRows.set(key, { ...row, role });
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return { projects, memberships, grants };
|
|
327
|
+
}
|