@cat-factory/workspaces 0.6.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 +21 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/accounts/AccountService.d.ts +68 -0
- package/dist/modules/accounts/AccountService.d.ts.map +1 -0
- package/dist/modules/accounts/AccountService.js +201 -0
- package/dist/modules/accounts/AccountService.js.map +1 -0
- package/dist/modules/invitations/InvitationService.d.ts +51 -0
- package/dist/modules/invitations/InvitationService.d.ts.map +1 -0
- package/dist/modules/invitations/InvitationService.js +154 -0
- package/dist/modules/invitations/InvitationService.js.map +1 -0
- package/dist/modules/users/UserService.d.ts +65 -0
- package/dist/modules/users/UserService.d.ts.map +1 -0
- package/dist/modules/users/UserService.js +175 -0
- package/dist/modules/users/UserService.js.map +1 -0
- package/dist/modules/workspaces/WorkspaceService.d.ts +77 -0
- package/dist/modules/workspaces/WorkspaceService.d.ts.map +1 -0
- package/dist/modules/workspaces/WorkspaceService.js +193 -0
- package/dist/modules/workspaces/WorkspaceService.js.map +1 -0
- package/package.json +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Igor Savin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { WorkspaceService, type WorkspaceServiceDependencies, } from './modules/workspaces/WorkspaceService.js';
|
|
2
|
+
export { AccountService, type AccountServiceDependencies, type AccountUser, } from './modules/accounts/AccountService.js';
|
|
3
|
+
export { UserService, type UserServiceDependencies, type IdentityProfile, } from './modules/users/UserService.js';
|
|
4
|
+
export { InvitationService, type InvitationServiceDependencies, type CreatedInvitation, } from './modules/invitations/InvitationService.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,gBAAgB,EAChB,KAAK,4BAA4B,GAClC,MAAM,0CAA0C,CAAA;AACjD,OAAO,EACL,cAAc,EACd,KAAK,0BAA0B,EAC/B,KAAK,WAAW,GACjB,MAAM,sCAAsC,CAAA;AAC7C,OAAO,EACL,WAAW,EACX,KAAK,uBAAuB,EAC5B,KAAK,eAAe,GACrB,MAAM,gCAAgC,CAAA;AACvC,OAAO,EACL,iBAAiB,EACjB,KAAK,6BAA6B,EAClC,KAAK,iBAAiB,GACvB,MAAM,4CAA4C,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Public surface of the tenancy base layer.
|
|
2
|
+
export { WorkspaceService, } from './modules/workspaces/WorkspaceService.js';
|
|
3
|
+
export { AccountService, } from './modules/accounts/AccountService.js';
|
|
4
|
+
export { UserService, } from './modules/users/UserService.js';
|
|
5
|
+
export { InvitationService, } from './modules/invitations/InvitationService.js';
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAE5C,OAAO,EACL,gBAAgB,GAEjB,MAAM,0CAA0C,CAAA;AACjD,OAAO,EACL,cAAc,GAGf,MAAM,sCAAsC,CAAA;AAC7C,OAAO,EACL,WAAW,GAGZ,MAAM,gCAAgC,CAAA;AACvC,OAAO,EACL,iBAAiB,GAGlB,MAAM,4CAA4C,CAAA"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Account, AccountMember, AccountRole, CreateAccountInput, UpdateAccountInput } from '@cat-factory/kernel';
|
|
2
|
+
import type { AccountRecord, AccountRepository, Membership, MembershipRepository, UserRepository } from '@cat-factory/kernel';
|
|
3
|
+
import type { Clock, IdGenerator } from '@cat-factory/kernel';
|
|
4
|
+
export interface AccountServiceDependencies {
|
|
5
|
+
accountRepository: AccountRepository;
|
|
6
|
+
membershipRepository: MembershipRepository;
|
|
7
|
+
idGenerator: IdGenerator;
|
|
8
|
+
clock: Clock;
|
|
9
|
+
/** Optional: resolve member display details (name/email/avatar) for the roster. */
|
|
10
|
+
userRepository?: UserRepository;
|
|
11
|
+
}
|
|
12
|
+
/** The signed-in identity the tenancy decisions are made against. */
|
|
13
|
+
export interface AccountUser {
|
|
14
|
+
/** Internal user id (`usr_*`). */
|
|
15
|
+
id: string;
|
|
16
|
+
/** GitHub login, when the user signed in via GitHub (else any display handle). */
|
|
17
|
+
login: string;
|
|
18
|
+
name: string | null;
|
|
19
|
+
}
|
|
20
|
+
export declare class AccountService {
|
|
21
|
+
private readonly deps;
|
|
22
|
+
constructor(deps: AccountServiceDependencies);
|
|
23
|
+
/**
|
|
24
|
+
* Ensure a user has a personal account (account-of-one) with an owner
|
|
25
|
+
* membership, creating it on first sign-in. Idempotent: keyed by the user's
|
|
26
|
+
* internal id, so repeated calls return the same account.
|
|
27
|
+
*/
|
|
28
|
+
ensurePersonalAccount(user: AccountUser): Promise<AccountRecord>;
|
|
29
|
+
/** Create a shared org account; the creator becomes its first owner. */
|
|
30
|
+
createOrg(user: AccountUser, input: CreateAccountInput): Promise<Account>;
|
|
31
|
+
/**
|
|
32
|
+
* Every account the user can see and switch between (personal first), each
|
|
33
|
+
* annotated with the caller's role. Ensures the personal account exists.
|
|
34
|
+
*/
|
|
35
|
+
listForUser(user: AccountUser): Promise<Account[]>;
|
|
36
|
+
/** The set of account ids a user belongs to (used to scope board visibility). */
|
|
37
|
+
accessibleAccountIds(userId: string): Promise<string[]>;
|
|
38
|
+
isMember(accountId: string, userId: string): Promise<boolean>;
|
|
39
|
+
/** Resolve the membership or throw 404 — the guard mutating routes use. */
|
|
40
|
+
requireMember(accountId: string, userId: string): Promise<Membership>;
|
|
41
|
+
/** A user's roles in an account (empty when not a member). */
|
|
42
|
+
rolesFor(accountId: string, userId: string): Promise<AccountRole[]>;
|
|
43
|
+
/** Whether a user holds a role in an account. */
|
|
44
|
+
hasRole(accountId: string, userId: string, role: AccountRole): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the membership and require the `admin` role — the guard every
|
|
47
|
+
* org-account-modifying route uses (settings, members, invitations, account-scoped
|
|
48
|
+
* credentials). Throws 404 for a non-member, 409 for a member without `admin`.
|
|
49
|
+
*/
|
|
50
|
+
requireAdmin(accountId: string, userId: string): Promise<Membership>;
|
|
51
|
+
get(accountId: string): Promise<AccountRecord | null>;
|
|
52
|
+
members(accountId: string): Promise<AccountMember[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Update an account's settings (today: the default cloud provider new services
|
|
55
|
+
* inherit). Owner-only. Returns the updated wire account so the caller can patch
|
|
56
|
+
* its switcher in place.
|
|
57
|
+
*/
|
|
58
|
+
updateSettings(accountId: string, actingUserId: string, input: UpdateAccountInput): Promise<Account>;
|
|
59
|
+
/**
|
|
60
|
+
* Add a member to an account. Only an admin may add, and only into an `org` account
|
|
61
|
+
* (a personal account stays an account-of-one). Defaults to the `developer` role.
|
|
62
|
+
*/
|
|
63
|
+
addMember(accountId: string, actingUserId: string, userId: string, roles?: AccountRole[]): Promise<AccountMember>;
|
|
64
|
+
/** Set a member's role set (admin-only). The acting admin cannot drop their OWN admin. */
|
|
65
|
+
setMemberRoles(accountId: string, actingUserId: string, targetUserId: string, roles: AccountRole[]): Promise<AccountMember>;
|
|
66
|
+
private ensureMembership;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=AccountService.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AccountService.d.ts","sourceRoot":"","sources":["../../../src/modules/accounts/AccountService.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { ConflictError, NotFoundError, ValidationError, assertFound } from '@cat-factory/kernel';
|
|
2
|
+
function toWire(account, roles) {
|
|
3
|
+
return {
|
|
4
|
+
id: account.id,
|
|
5
|
+
type: account.type,
|
|
6
|
+
name: account.name,
|
|
7
|
+
githubAccountLogin: account.githubAccountLogin,
|
|
8
|
+
createdAt: account.createdAt,
|
|
9
|
+
roles,
|
|
10
|
+
...(account.defaultCloudProvider ? { defaultCloudProvider: account.defaultCloudProvider } : {}),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function toMember(m) {
|
|
14
|
+
return { accountId: m.accountId, userId: m.userId, roles: m.roles, createdAt: m.createdAt };
|
|
15
|
+
}
|
|
16
|
+
export class AccountService {
|
|
17
|
+
deps;
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.deps = deps;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Ensure a user has a personal account (account-of-one) with an owner
|
|
23
|
+
* membership, creating it on first sign-in. Idempotent: keyed by the user's
|
|
24
|
+
* internal id, so repeated calls return the same account.
|
|
25
|
+
*/
|
|
26
|
+
async ensurePersonalAccount(user) {
|
|
27
|
+
const existing = await this.deps.accountRepository.findPersonalByUser(user.id);
|
|
28
|
+
if (existing) {
|
|
29
|
+
// Self-heal a missing admin membership (e.g. partially-applied prior run).
|
|
30
|
+
await this.ensureMembership(existing.id, user.id, ['admin']);
|
|
31
|
+
return existing;
|
|
32
|
+
}
|
|
33
|
+
const account = {
|
|
34
|
+
id: this.deps.idGenerator.next('acc'),
|
|
35
|
+
type: 'personal',
|
|
36
|
+
name: user.name?.trim() || user.login,
|
|
37
|
+
githubAccountLogin: user.login,
|
|
38
|
+
ownerUserId: user.id,
|
|
39
|
+
createdAt: this.deps.clock.now(),
|
|
40
|
+
};
|
|
41
|
+
await this.deps.accountRepository.create(account);
|
|
42
|
+
await this.ensureMembership(account.id, user.id, ['admin']);
|
|
43
|
+
return account;
|
|
44
|
+
}
|
|
45
|
+
/** Create a shared org account; the creator becomes its first owner. */
|
|
46
|
+
async createOrg(user, input) {
|
|
47
|
+
const account = {
|
|
48
|
+
id: this.deps.idGenerator.next('acc'),
|
|
49
|
+
type: 'org',
|
|
50
|
+
name: input.name.trim(),
|
|
51
|
+
githubAccountLogin: input.githubAccountLogin?.trim() || null,
|
|
52
|
+
ownerUserId: null,
|
|
53
|
+
createdAt: this.deps.clock.now(),
|
|
54
|
+
};
|
|
55
|
+
await this.deps.accountRepository.create(account);
|
|
56
|
+
await this.ensureMembership(account.id, user.id, ['admin']);
|
|
57
|
+
return toWire(account, ['admin']);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Every account the user can see and switch between (personal first), each
|
|
61
|
+
* annotated with the caller's role. Ensures the personal account exists.
|
|
62
|
+
*/
|
|
63
|
+
async listForUser(user) {
|
|
64
|
+
await this.ensurePersonalAccount(user);
|
|
65
|
+
const memberships = await this.deps.membershipRepository.listByUser(user.id);
|
|
66
|
+
const accounts = [];
|
|
67
|
+
for (const m of memberships) {
|
|
68
|
+
const account = await this.deps.accountRepository.get(m.accountId);
|
|
69
|
+
if (account)
|
|
70
|
+
accounts.push(toWire(account, m.roles));
|
|
71
|
+
}
|
|
72
|
+
// Personal accounts first, then orgs, each alphabetical — a stable switcher order.
|
|
73
|
+
return accounts.sort((a, b) => (a.type === 'personal' ? 0 : 1) - (b.type === 'personal' ? 0 : 1) ||
|
|
74
|
+
a.name.localeCompare(b.name));
|
|
75
|
+
}
|
|
76
|
+
/** The set of account ids a user belongs to (used to scope board visibility). */
|
|
77
|
+
async accessibleAccountIds(userId) {
|
|
78
|
+
const memberships = await this.deps.membershipRepository.listByUser(userId);
|
|
79
|
+
return memberships.map((m) => m.accountId);
|
|
80
|
+
}
|
|
81
|
+
async isMember(accountId, userId) {
|
|
82
|
+
return (await this.deps.membershipRepository.get(accountId, userId)) !== null;
|
|
83
|
+
}
|
|
84
|
+
/** Resolve the membership or throw 404 — the guard mutating routes use. */
|
|
85
|
+
async requireMember(accountId, userId) {
|
|
86
|
+
const membership = await this.deps.membershipRepository.get(accountId, userId);
|
|
87
|
+
if (!membership)
|
|
88
|
+
throw new NotFoundError('Account', accountId);
|
|
89
|
+
return membership;
|
|
90
|
+
}
|
|
91
|
+
/** A user's roles in an account (empty when not a member). */
|
|
92
|
+
async rolesFor(accountId, userId) {
|
|
93
|
+
const membership = await this.deps.membershipRepository.get(accountId, userId);
|
|
94
|
+
return membership?.roles ?? [];
|
|
95
|
+
}
|
|
96
|
+
/** Whether a user holds a role in an account. */
|
|
97
|
+
async hasRole(accountId, userId, role) {
|
|
98
|
+
return (await this.rolesFor(accountId, userId)).includes(role);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the membership and require the `admin` role — the guard every
|
|
102
|
+
* org-account-modifying route uses (settings, members, invitations, account-scoped
|
|
103
|
+
* credentials). Throws 404 for a non-member, 409 for a member without `admin`.
|
|
104
|
+
*/
|
|
105
|
+
async requireAdmin(accountId, userId) {
|
|
106
|
+
const membership = await this.requireMember(accountId, userId);
|
|
107
|
+
if (!membership.roles.includes('admin')) {
|
|
108
|
+
throw new ConflictError('Only an account admin can modify the organization account');
|
|
109
|
+
}
|
|
110
|
+
return membership;
|
|
111
|
+
}
|
|
112
|
+
get(accountId) {
|
|
113
|
+
return this.deps.accountRepository.get(accountId);
|
|
114
|
+
}
|
|
115
|
+
async members(accountId) {
|
|
116
|
+
const list = await this.deps.membershipRepository.listByAccount(accountId);
|
|
117
|
+
const users = this.deps.userRepository;
|
|
118
|
+
if (!users)
|
|
119
|
+
return list.map(toMember);
|
|
120
|
+
// Enrich the roster with each member's display details for the UI — one bulk load
|
|
121
|
+
// rather than a query per member.
|
|
122
|
+
const records = await users.listByIds(list.map((m) => m.userId));
|
|
123
|
+
const byId = new Map(records.map((u) => [u.id, u]));
|
|
124
|
+
return list.map((m) => {
|
|
125
|
+
const user = byId.get(m.userId);
|
|
126
|
+
return {
|
|
127
|
+
...toMember(m),
|
|
128
|
+
name: user?.name ?? null,
|
|
129
|
+
email: user?.email ?? null,
|
|
130
|
+
avatarUrl: user?.avatarUrl ?? null,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Update an account's settings (today: the default cloud provider new services
|
|
136
|
+
* inherit). Owner-only. Returns the updated wire account so the caller can patch
|
|
137
|
+
* its switcher in place.
|
|
138
|
+
*/
|
|
139
|
+
async updateSettings(accountId, actingUserId, input) {
|
|
140
|
+
const acting = await this.requireAdmin(accountId, actingUserId);
|
|
141
|
+
// An explicit key (even `undefined`) means "clear"; an absent key leaves it.
|
|
142
|
+
if ('defaultCloudProvider' in input) {
|
|
143
|
+
await this.deps.accountRepository.updateSettings(accountId, {
|
|
144
|
+
defaultCloudProvider: input.defaultCloudProvider ?? null,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const account = assertFound(await this.deps.accountRepository.get(accountId), 'Account', accountId);
|
|
148
|
+
return toWire(account, acting.roles);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Add a member to an account. Only an admin may add, and only into an `org` account
|
|
152
|
+
* (a personal account stays an account-of-one). Defaults to the `developer` role.
|
|
153
|
+
*/
|
|
154
|
+
async addMember(accountId, actingUserId, userId, roles = ['developer']) {
|
|
155
|
+
// Authorize first so a non-admin/non-member can't probe an account's existence or
|
|
156
|
+
// type (404 before any ValidationError leak).
|
|
157
|
+
await this.requireAdmin(accountId, actingUserId);
|
|
158
|
+
const account = assertFound(await this.deps.accountRepository.get(accountId), 'Account', accountId);
|
|
159
|
+
if (account.type === 'personal') {
|
|
160
|
+
throw new ValidationError('Cannot add members to a personal account');
|
|
161
|
+
}
|
|
162
|
+
const membership = {
|
|
163
|
+
accountId,
|
|
164
|
+
userId,
|
|
165
|
+
roles: normalizeRoles(roles),
|
|
166
|
+
createdAt: this.deps.clock.now(),
|
|
167
|
+
};
|
|
168
|
+
await this.deps.membershipRepository.upsert(membership);
|
|
169
|
+
return toMember(membership);
|
|
170
|
+
}
|
|
171
|
+
/** Set a member's role set (admin-only). The acting admin cannot drop their OWN admin. */
|
|
172
|
+
async setMemberRoles(accountId, actingUserId, targetUserId, roles) {
|
|
173
|
+
await this.requireAdmin(accountId, actingUserId);
|
|
174
|
+
const target = await this.requireMember(accountId, targetUserId);
|
|
175
|
+
const next = normalizeRoles(roles);
|
|
176
|
+
if (actingUserId === targetUserId && !next.includes('admin')) {
|
|
177
|
+
throw new ConflictError('You cannot remove your own admin role');
|
|
178
|
+
}
|
|
179
|
+
const membership = { ...target, roles: next };
|
|
180
|
+
await this.deps.membershipRepository.upsert(membership);
|
|
181
|
+
return toMember(membership);
|
|
182
|
+
}
|
|
183
|
+
async ensureMembership(accountId, userId, roles) {
|
|
184
|
+
if (await this.deps.membershipRepository.get(accountId, userId))
|
|
185
|
+
return;
|
|
186
|
+
await this.deps.membershipRepository.upsert({
|
|
187
|
+
accountId,
|
|
188
|
+
userId,
|
|
189
|
+
roles: normalizeRoles(roles),
|
|
190
|
+
createdAt: this.deps.clock.now(),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** De-duplicate a role set, preserving order, defaulting to `developer` when empty. */
|
|
195
|
+
function normalizeRoles(roles) {
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
for (const r of roles)
|
|
198
|
+
seen.add(r);
|
|
199
|
+
return seen.size > 0 ? [...seen] : ['developer'];
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=AccountService.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AccountService.js","sourceRoot":"","sources":["../../../src/modules/accounts/AccountService.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AccountInvitation, AccountRole } from '@cat-factory/contracts';
|
|
2
|
+
import type { AccountInvitationRecord, AccountInvitationRepository, AccountRepository, Clock, EmailSender, IdGenerator, MembershipRepository } from '@cat-factory/kernel';
|
|
3
|
+
export interface InvitationServiceDependencies {
|
|
4
|
+
invitationRepository: AccountInvitationRepository;
|
|
5
|
+
accountRepository: AccountRepository;
|
|
6
|
+
membershipRepository: MembershipRepository;
|
|
7
|
+
idGenerator: IdGenerator;
|
|
8
|
+
clock: Clock;
|
|
9
|
+
/**
|
|
10
|
+
* Optional: resolve the account's configured (DB-stored, per-account) email sender
|
|
11
|
+
* at send time. Absent or returning null ⇒ the accept link is returned for manual
|
|
12
|
+
* sharing instead of being emailed.
|
|
13
|
+
*/
|
|
14
|
+
resolveEmailSender?: (accountId: string) => Promise<EmailSender | null>;
|
|
15
|
+
/** Base URL the accept link points at (the SPA origin). */
|
|
16
|
+
appBaseUrl?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface CreatedInvitation {
|
|
19
|
+
invitation: AccountInvitation;
|
|
20
|
+
/** The raw accept token + link (only available at creation; never re-derivable). */
|
|
21
|
+
token: string;
|
|
22
|
+
acceptUrl: string | null;
|
|
23
|
+
/** Whether the invite email was actually sent (vs. needing manual link sharing). */
|
|
24
|
+
emailed: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare class InvitationService {
|
|
27
|
+
private readonly deps;
|
|
28
|
+
constructor(deps: InvitationServiceDependencies);
|
|
29
|
+
/** Invite a teammate by email. Admin-only, org accounts only. */
|
|
30
|
+
invite(accountId: string, actingUserId: string, email: string, roles?: AccountRole[]): Promise<CreatedInvitation>;
|
|
31
|
+
list(accountId: string): Promise<AccountInvitation[]>;
|
|
32
|
+
/** Revoke a pending invitation (admin-only). */
|
|
33
|
+
revoke(accountId: string, actingUserId: string, invitationId: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Peek at a pending invitation by token (no side effects) — lets the SPA show the
|
|
36
|
+
* org name on the accept screen and decide whether the user must sign up first.
|
|
37
|
+
*/
|
|
38
|
+
peek(token: string): Promise<AccountInvitationRecord | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Redeem an invitation: grant the user membership in the org and mark it accepted.
|
|
41
|
+
* Returns the account id joined.
|
|
42
|
+
*
|
|
43
|
+
* The redemption is bound to the invited email: the accepting user's verified email
|
|
44
|
+
* must match the address the invite was sent to. This stops a leaked accept link from
|
|
45
|
+
* admitting an arbitrary account (the invite token also short-circuits the sign-in
|
|
46
|
+
* allowlist, so without this binding any leaked link would be a private-deployment
|
|
47
|
+
* bypass). A user with no known email cannot redeem (fail closed).
|
|
48
|
+
*/
|
|
49
|
+
accept(token: string, userId: string, userEmail: string | null): Promise<string>;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=InvitationService.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"InvitationService.d.ts","sourceRoot":"","sources":["../../../src/modules/invitations/InvitationService.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAC5E,OAAO,KAAK,EACV,uBAAuB,EACvB,2BAA2B,EAC3B,iBAAiB,EACjB,KAAK,EACL,WAAW,EACX,WAAW,EAEX,oBAAoB,EACrB,MAAM,qBAAqB,CAAA;AAa5B,MAAM,WAAW,6BAA6B;IAC5C,oBAAoB,EAAE,2BAA2B,CAAA;IACjD,iBAAiB,EAAE,iBAAiB,CAAA;IACpC,oBAAoB,EAAE,oBAAoB,CAAA;IAC1C,WAAW,EAAE,WAAW,CAAA;IACxB,KAAK,EAAE,KAAK,CAAA;IACZ;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IACvE,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAqBD,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,iBAAiB,CAAA;IAC7B,oFAAoF;IACpF,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,oFAAoF;IACpF,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,qBAAa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAAjC,YAA6B,IAAI,EAAE,6BAA6B,EAAI;IAEpE,iEAAiE;IAC3D,MAAM,CACV,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,WAAW,EAAkB,GACnC,OAAO,CAAC,iBAAiB,CAAC,CAuD5B;IAED,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAEpD;IAED,gDAAgD;IAC1C,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAYzF;IAED;;;OAGG;IACG,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAKjE;IAED;;;;;;;;;OASG;IACG,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAoBrF;CACF"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { ConflictError, NotFoundError, ValidationError, assertFound } from '@cat-factory/kernel';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// InvitationService: invite teammates into an org account by email. An owner mints
|
|
4
|
+
// an invitation; the invitee redeems an opaque token (delivered by email) to gain
|
|
5
|
+
// membership. Only the token's SHA-256 hash is stored — the raw token lives only in
|
|
6
|
+
// the emailed accept link. Redeeming for a brand-new email is what lets a person
|
|
7
|
+
// without a GitHub account join (their user is created by the auth signup path,
|
|
8
|
+
// then this grants the org membership).
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11
|
+
/** SHA-256 hex digest — Web Crypto, runs on both runtimes. */
|
|
12
|
+
async function sha256Hex(input) {
|
|
13
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
|
14
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
15
|
+
}
|
|
16
|
+
function toWire(record) {
|
|
17
|
+
return {
|
|
18
|
+
id: record.id,
|
|
19
|
+
accountId: record.accountId,
|
|
20
|
+
email: record.email,
|
|
21
|
+
roles: record.roles,
|
|
22
|
+
status: record.status,
|
|
23
|
+
invitedBy: record.invitedBy,
|
|
24
|
+
expiresAt: record.expiresAt,
|
|
25
|
+
createdAt: record.createdAt,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export class InvitationService {
|
|
29
|
+
deps;
|
|
30
|
+
constructor(deps) {
|
|
31
|
+
this.deps = deps;
|
|
32
|
+
}
|
|
33
|
+
/** Invite a teammate by email. Admin-only, org accounts only. */
|
|
34
|
+
async invite(accountId, actingUserId, email, roles = ['developer']) {
|
|
35
|
+
const account = assertFound(await this.deps.accountRepository.get(accountId), 'Account', accountId);
|
|
36
|
+
if (account.type === 'personal') {
|
|
37
|
+
throw new ValidationError('Cannot invite members to a personal account');
|
|
38
|
+
}
|
|
39
|
+
const acting = await this.deps.membershipRepository.get(accountId, actingUserId);
|
|
40
|
+
if (!acting)
|
|
41
|
+
throw new NotFoundError('Account', accountId);
|
|
42
|
+
if (!acting.roles.includes('admin')) {
|
|
43
|
+
throw new ConflictError('Only an account admin can invite members');
|
|
44
|
+
}
|
|
45
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
46
|
+
// Supersede any still-pending invite to the same address in this account, so only
|
|
47
|
+
// the freshly-minted token stays live (no pile-up of redeemable links per email).
|
|
48
|
+
const pending = await this.deps.invitationRepository.listByAccount(accountId);
|
|
49
|
+
for (const prior of pending) {
|
|
50
|
+
if (prior.status === 'pending' && prior.email === normalizedEmail) {
|
|
51
|
+
await this.deps.invitationRepository.setStatus(prior.id, 'revoked');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const token = `${crypto.randomUUID()}${crypto.randomUUID()}`.replace(/-/g, '');
|
|
55
|
+
const record = {
|
|
56
|
+
id: this.deps.idGenerator.next('inv'),
|
|
57
|
+
accountId,
|
|
58
|
+
email: normalizedEmail,
|
|
59
|
+
roles: roles.length > 0 ? roles : ['developer'],
|
|
60
|
+
tokenHash: await sha256Hex(token),
|
|
61
|
+
invitedBy: actingUserId,
|
|
62
|
+
status: 'pending',
|
|
63
|
+
expiresAt: this.deps.clock.now() + INVITE_TTL_MS,
|
|
64
|
+
createdAt: this.deps.clock.now(),
|
|
65
|
+
};
|
|
66
|
+
await this.deps.invitationRepository.create(record);
|
|
67
|
+
const acceptUrl = this.deps.appBaseUrl
|
|
68
|
+
? `${this.deps.appBaseUrl.replace(/\/$/, '')}/invite?token=${token}`
|
|
69
|
+
: null;
|
|
70
|
+
const sender = this.deps.resolveEmailSender
|
|
71
|
+
? await this.deps.resolveEmailSender(accountId)
|
|
72
|
+
: null;
|
|
73
|
+
let emailed = false;
|
|
74
|
+
if (sender && acceptUrl) {
|
|
75
|
+
await sender.send({
|
|
76
|
+
to: normalizedEmail,
|
|
77
|
+
subject: `You've been invited to ${account.name} on Cat Factory`,
|
|
78
|
+
text: `You've been invited to join ${account.name}. Accept: ${acceptUrl}`,
|
|
79
|
+
html: invitationEmailHtml(account.name, acceptUrl),
|
|
80
|
+
});
|
|
81
|
+
emailed = true;
|
|
82
|
+
}
|
|
83
|
+
return { invitation: toWire(record), token, acceptUrl, emailed };
|
|
84
|
+
}
|
|
85
|
+
list(accountId) {
|
|
86
|
+
return this.deps.invitationRepository.listByAccount(accountId).then((rows) => rows.map(toWire));
|
|
87
|
+
}
|
|
88
|
+
/** Revoke a pending invitation (admin-only). */
|
|
89
|
+
async revoke(accountId, actingUserId, invitationId) {
|
|
90
|
+
const acting = await this.deps.membershipRepository.get(accountId, actingUserId);
|
|
91
|
+
if (!acting?.roles.includes('admin')) {
|
|
92
|
+
throw new ConflictError('Only an account admin can revoke invitations');
|
|
93
|
+
}
|
|
94
|
+
const invitation = assertFound(await this.deps.invitationRepository.get(invitationId), 'Invitation', invitationId);
|
|
95
|
+
if (invitation.accountId !== accountId)
|
|
96
|
+
throw new NotFoundError('Invitation', invitationId);
|
|
97
|
+
await this.deps.invitationRepository.setStatus(invitationId, 'revoked');
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Peek at a pending invitation by token (no side effects) — lets the SPA show the
|
|
101
|
+
* org name on the accept screen and decide whether the user must sign up first.
|
|
102
|
+
*/
|
|
103
|
+
async peek(token) {
|
|
104
|
+
const record = await this.deps.invitationRepository.findByTokenHash(await sha256Hex(token));
|
|
105
|
+
if (!record || record.status !== 'pending')
|
|
106
|
+
return null;
|
|
107
|
+
if (record.expiresAt < this.deps.clock.now())
|
|
108
|
+
return null;
|
|
109
|
+
return record;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Redeem an invitation: grant the user membership in the org and mark it accepted.
|
|
113
|
+
* Returns the account id joined.
|
|
114
|
+
*
|
|
115
|
+
* The redemption is bound to the invited email: the accepting user's verified email
|
|
116
|
+
* must match the address the invite was sent to. This stops a leaked accept link from
|
|
117
|
+
* admitting an arbitrary account (the invite token also short-circuits the sign-in
|
|
118
|
+
* allowlist, so without this binding any leaked link would be a private-deployment
|
|
119
|
+
* bypass). A user with no known email cannot redeem (fail closed).
|
|
120
|
+
*/
|
|
121
|
+
async accept(token, userId, userEmail) {
|
|
122
|
+
const record = await this.deps.invitationRepository.findByTokenHash(await sha256Hex(token));
|
|
123
|
+
if (!record || record.status !== 'pending') {
|
|
124
|
+
throw new NotFoundError('Invitation', 'token');
|
|
125
|
+
}
|
|
126
|
+
if (record.expiresAt < this.deps.clock.now()) {
|
|
127
|
+
throw new ConflictError('This invitation has expired');
|
|
128
|
+
}
|
|
129
|
+
if (!userEmail || userEmail.toLowerCase().trim() !== record.email) {
|
|
130
|
+
throw new ConflictError('This invitation was sent to a different email address');
|
|
131
|
+
}
|
|
132
|
+
const membership = {
|
|
133
|
+
accountId: record.accountId,
|
|
134
|
+
userId,
|
|
135
|
+
roles: record.roles,
|
|
136
|
+
createdAt: this.deps.clock.now(),
|
|
137
|
+
};
|
|
138
|
+
await this.deps.membershipRepository.upsert(membership);
|
|
139
|
+
await this.deps.invitationRepository.setStatus(record.id, 'accepted');
|
|
140
|
+
return record.accountId;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function invitationEmailHtml(accountName, acceptUrl) {
|
|
144
|
+
return `<!doctype html><html><body style="font-family:sans-serif">
|
|
145
|
+
<h2>You've been invited to ${escapeHtml(accountName)}</h2>
|
|
146
|
+
<p>You've been invited to collaborate on <strong>${escapeHtml(accountName)}</strong> in Cat Factory.</p>
|
|
147
|
+
<p><a href="${acceptUrl}" style="display:inline-block;padding:10px 18px;background:#111;color:#fff;border-radius:6px;text-decoration:none">Accept invitation</a></p>
|
|
148
|
+
<p>Or paste this link into your browser:<br>${acceptUrl}</p>
|
|
149
|
+
</body></html>`;
|
|
150
|
+
}
|
|
151
|
+
function escapeHtml(s) {
|
|
152
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=InvitationService.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"InvitationService.js","sourceRoot":"","sources":["../../../src/modules/invitations/InvitationService.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Clock, IdGenerator, IdentityProvider, PasswordHasher, UserIdentityRecord, UserRecord, UserRepository } from '@cat-factory/kernel';
|
|
2
|
+
export interface UserServiceDependencies {
|
|
3
|
+
userRepository: UserRepository;
|
|
4
|
+
passwordHasher: PasswordHasher;
|
|
5
|
+
idGenerator: IdGenerator;
|
|
6
|
+
clock: Clock;
|
|
7
|
+
}
|
|
8
|
+
/** Profile details captured from an external identity provider. */
|
|
9
|
+
export interface IdentityProfile {
|
|
10
|
+
name?: string | null;
|
|
11
|
+
email?: string | null;
|
|
12
|
+
avatarUrl?: string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the provider has verified the user owns `email`. Only a verified email is
|
|
15
|
+
* trusted to link this identity onto an existing same-email user (else two people
|
|
16
|
+
* who happen to share an email could merge into one account).
|
|
17
|
+
*/
|
|
18
|
+
emailVerified?: boolean;
|
|
19
|
+
/** Provider-specific extras to persist as identity metadata (e.g. github login). */
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export declare class UserService {
|
|
23
|
+
private readonly deps;
|
|
24
|
+
constructor(deps: UserServiceDependencies);
|
|
25
|
+
get(id: string): Promise<UserRecord | null>;
|
|
26
|
+
/** The user behind an external identity, or null (no side effects). */
|
|
27
|
+
findByIdentity(provider: IdentityProvider, subject: string): Promise<UserRecord | null>;
|
|
28
|
+
/** Every linked login identity for a user (the account-settings "connected logins"). */
|
|
29
|
+
listIdentities(userId: string): Promise<UserIdentityRecord[]>;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the user behind an external identity, creating the user + linking the
|
|
32
|
+
* identity on first sight. Idempotent on `(provider, subject)`: repeated logins
|
|
33
|
+
* return the same `usr_*` id. Used by all GitHub/Google login paths.
|
|
34
|
+
*/
|
|
35
|
+
findOrCreateByIdentity(provider: IdentityProvider, subject: string, profile?: IdentityProfile): Promise<UserRecord>;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a user's primary email was proven by an OAuth provider rather than a
|
|
38
|
+
* self-asserted password signup. `users.email` is only ever written by (a) a
|
|
39
|
+
* verified OAuth create/link or (b) a `password` signup; password is the sole
|
|
40
|
+
* unverified writer. So "owns an email AND has a non-password identity" is a sound
|
|
41
|
+
* proxy for "email is provider-verified" — used to refuse merging a verified login
|
|
42
|
+
* onto a squatted, password-only account.
|
|
43
|
+
*/
|
|
44
|
+
private emailIsProviderVerified;
|
|
45
|
+
/** Link an additional identity to an existing user (onboarding follow-up). */
|
|
46
|
+
linkIdentity(userId: string, provider: IdentityProvider, subject: string, profile?: IdentityProfile): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Register a new email/password user. The email is the password identity's
|
|
49
|
+
* subject; rejects when one already exists. Returns the created user.
|
|
50
|
+
*/
|
|
51
|
+
signupWithPassword(input: {
|
|
52
|
+
email: string;
|
|
53
|
+
password: string;
|
|
54
|
+
name?: string | null;
|
|
55
|
+
}): Promise<UserRecord>;
|
|
56
|
+
/**
|
|
57
|
+
* Verify an email/password login, returning the user on success or null on a bad
|
|
58
|
+
* email/password (the caller maps null → 401, never leaking which was wrong).
|
|
59
|
+
*/
|
|
60
|
+
verifyPassword(input: {
|
|
61
|
+
email: string;
|
|
62
|
+
password: string;
|
|
63
|
+
}): Promise<UserRecord | null>;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=UserService.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UserService.d.ts","sourceRoot":"","sources":["../../../src/modules/users/UserService.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,KAAK,EACL,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,UAAU,EACV,cAAc,EACf,MAAM,qBAAqB,CAAA;AAU5B,MAAM,WAAW,uBAAuB;IACtC,cAAc,EAAE,cAAc,CAAA;IAC9B,cAAc,EAAE,cAAc,CAAA;IAC9B,WAAW,EAAE,WAAW,CAAA;IACxB,KAAK,EAAE,KAAK,CAAA;CACb;AAED,mEAAmE;AACnE,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACnC;AAcD,qBAAa,WAAW;IACV,OAAO,CAAC,QAAQ,CAAC,IAAI;IAAjC,YAA6B,IAAI,EAAE,uBAAuB,EAAI;IAE9D,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAE1C;IAED,uEAAuE;IACvE,cAAc,CAAC,QAAQ,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAEtF;IAED,wFAAwF;IACxF,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAE5D;IAED;;;;OAIG;IACG,sBAAsB,CAC1B,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,UAAU,CAAC,CAwDrB;IAED;;;;;;;OAOG;YACW,uBAAuB;IAKrC,8EAA8E;IACxE,YAAY,CAChB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,IAAI,CAAC,CASf;IAED;;;OAGG;IACG,kBAAkB,CAAC,KAAK,EAAE;QAC9B,KAAK,EAAE,MAAM,CAAA;QACb,QAAQ,EAAE,MAAM,CAAA;QAChB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KACrB,GAAG,OAAO,CAAC,UAAU,CAAC,CA8BtB;IAED;;;OAGG;IACG,cAAc,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAqB3F;CACF"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { ConflictError, ValidationError } from '@cat-factory/kernel';
|
|
2
|
+
// A dummy PHC hash the password verify path runs against when no password identity
|
|
3
|
+
// exists, so a miss costs the same PBKDF2 work as a hit and response time can't be used
|
|
4
|
+
// to enumerate which emails are registered. It is computed once per process from the
|
|
5
|
+
// REAL hasher (over a random, unguessable input) so its cost always tracks the hasher's
|
|
6
|
+
// current iteration count — a hardcoded string would silently drift if the default cost
|
|
7
|
+
// were raised, reopening the timing oracle. Cached process-wide (not per instance) so
|
|
8
|
+
// every miss pays exactly one PBKDF2, matching the single derivation a hit performs.
|
|
9
|
+
let dummyHashPromise;
|
|
10
|
+
function dummyPasswordHash(hasher) {
|
|
11
|
+
return (dummyHashPromise ??= hasher.hash(crypto.randomUUID()));
|
|
12
|
+
}
|
|
13
|
+
export class UserService {
|
|
14
|
+
deps;
|
|
15
|
+
constructor(deps) {
|
|
16
|
+
this.deps = deps;
|
|
17
|
+
}
|
|
18
|
+
get(id) {
|
|
19
|
+
return this.deps.userRepository.get(id);
|
|
20
|
+
}
|
|
21
|
+
/** The user behind an external identity, or null (no side effects). */
|
|
22
|
+
findByIdentity(provider, subject) {
|
|
23
|
+
return this.deps.userRepository.findByIdentity(provider, subject);
|
|
24
|
+
}
|
|
25
|
+
/** Every linked login identity for a user (the account-settings "connected logins"). */
|
|
26
|
+
listIdentities(userId) {
|
|
27
|
+
return this.deps.userRepository.listIdentities(userId);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the user behind an external identity, creating the user + linking the
|
|
31
|
+
* identity on first sight. Idempotent on `(provider, subject)`: repeated logins
|
|
32
|
+
* return the same `usr_*` id. Used by all GitHub/Google login paths.
|
|
33
|
+
*/
|
|
34
|
+
async findOrCreateByIdentity(provider, subject, profile = {}) {
|
|
35
|
+
const existing = await this.deps.userRepository.findByIdentity(provider, subject);
|
|
36
|
+
if (existing)
|
|
37
|
+
return existing;
|
|
38
|
+
let email = profile.email?.toLowerCase().trim() || null;
|
|
39
|
+
// Is this email already owned by another user? (Unique-index-safe handling below.)
|
|
40
|
+
const emailOwner = email ? await this.deps.userRepository.findByEmail(email) : null;
|
|
41
|
+
if (emailOwner) {
|
|
42
|
+
if (profile.emailVerified && (await this.emailIsProviderVerified(emailOwner.id))) {
|
|
43
|
+
// A second login provider for the same person — attach this identity to the
|
|
44
|
+
// existing same-email user instead of creating a duplicate (which would collide
|
|
45
|
+
// on the unique email index and 500). Only safe when the existing owner's email
|
|
46
|
+
// was itself proven by an OAuth provider, never by a self-asserted signup.
|
|
47
|
+
await this.deps.userRepository.linkIdentity({
|
|
48
|
+
userId: emailOwner.id,
|
|
49
|
+
provider,
|
|
50
|
+
subject,
|
|
51
|
+
secret: null,
|
|
52
|
+
metadata: profile.metadata ? JSON.stringify(profile.metadata) : null,
|
|
53
|
+
createdAt: this.deps.clock.now(),
|
|
54
|
+
});
|
|
55
|
+
return emailOwner;
|
|
56
|
+
}
|
|
57
|
+
if (profile.emailVerified) {
|
|
58
|
+
// The email is owned only via an UNVERIFIED password signup (the owner has no
|
|
59
|
+
// OAuth identity). Password signup never proves email ownership, so that claim
|
|
60
|
+
// can't block the genuinely-verified party from this provider. Release the email
|
|
61
|
+
// from the squatting account and let the verified login take it on a fresh user.
|
|
62
|
+
// (The squatter keeps its now-emailless, password-only account; acceptable per
|
|
63
|
+
// the pre-1.0 policy — and it stops a pre-registration account-hijack.)
|
|
64
|
+
await this.deps.userRepository.update(emailOwner.id, { email: null });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// Unverified and the email belongs to someone else: never trusted to claim or
|
|
68
|
+
// merge it, and storing it would collide on the unique index. Create a distinct
|
|
69
|
+
// user with no email rather than failing the login.
|
|
70
|
+
email = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const user = {
|
|
74
|
+
id: this.deps.idGenerator.next('usr'),
|
|
75
|
+
name: profile.name?.trim() || null,
|
|
76
|
+
email,
|
|
77
|
+
avatarUrl: profile.avatarUrl || null,
|
|
78
|
+
createdAt: this.deps.clock.now(),
|
|
79
|
+
};
|
|
80
|
+
await this.deps.userRepository.create(user);
|
|
81
|
+
await this.deps.userRepository.linkIdentity({
|
|
82
|
+
userId: user.id,
|
|
83
|
+
provider,
|
|
84
|
+
subject,
|
|
85
|
+
secret: null,
|
|
86
|
+
metadata: profile.metadata ? JSON.stringify(profile.metadata) : null,
|
|
87
|
+
createdAt: this.deps.clock.now(),
|
|
88
|
+
});
|
|
89
|
+
return user;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Whether a user's primary email was proven by an OAuth provider rather than a
|
|
93
|
+
* self-asserted password signup. `users.email` is only ever written by (a) a
|
|
94
|
+
* verified OAuth create/link or (b) a `password` signup; password is the sole
|
|
95
|
+
* unverified writer. So "owns an email AND has a non-password identity" is a sound
|
|
96
|
+
* proxy for "email is provider-verified" — used to refuse merging a verified login
|
|
97
|
+
* onto a squatted, password-only account.
|
|
98
|
+
*/
|
|
99
|
+
async emailIsProviderVerified(userId) {
|
|
100
|
+
const identities = await this.deps.userRepository.listIdentities(userId);
|
|
101
|
+
return identities.some((i) => i.provider !== 'password');
|
|
102
|
+
}
|
|
103
|
+
/** Link an additional identity to an existing user (onboarding follow-up). */
|
|
104
|
+
async linkIdentity(userId, provider, subject, profile = {}) {
|
|
105
|
+
await this.deps.userRepository.linkIdentity({
|
|
106
|
+
userId,
|
|
107
|
+
provider,
|
|
108
|
+
subject,
|
|
109
|
+
secret: null,
|
|
110
|
+
metadata: profile.metadata ? JSON.stringify(profile.metadata) : null,
|
|
111
|
+
createdAt: this.deps.clock.now(),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Register a new email/password user. The email is the password identity's
|
|
116
|
+
* subject; rejects when one already exists. Returns the created user.
|
|
117
|
+
*/
|
|
118
|
+
async signupWithPassword(input) {
|
|
119
|
+
const email = input.email.toLowerCase().trim();
|
|
120
|
+
if (!email)
|
|
121
|
+
throw new ValidationError('Email is required');
|
|
122
|
+
if (input.password.length < 8) {
|
|
123
|
+
throw new ValidationError('Password must be at least 8 characters');
|
|
124
|
+
}
|
|
125
|
+
// Reject if ANY user already owns this email (not just a prior password identity):
|
|
126
|
+
// attaching a password to an existing OAuth-only account via an unauthenticated
|
|
127
|
+
// signup would be account takeover, and a duplicate would collide on the unique
|
|
128
|
+
// email index. The owner can add a password later from an authenticated session.
|
|
129
|
+
if (await this.deps.userRepository.findByEmail(email)) {
|
|
130
|
+
throw new ConflictError('An account with that email already exists');
|
|
131
|
+
}
|
|
132
|
+
const user = {
|
|
133
|
+
id: this.deps.idGenerator.next('usr'),
|
|
134
|
+
name: input.name?.trim() || null,
|
|
135
|
+
email,
|
|
136
|
+
avatarUrl: null,
|
|
137
|
+
createdAt: this.deps.clock.now(),
|
|
138
|
+
};
|
|
139
|
+
await this.deps.userRepository.create(user);
|
|
140
|
+
await this.deps.userRepository.linkIdentity({
|
|
141
|
+
userId: user.id,
|
|
142
|
+
provider: 'password',
|
|
143
|
+
subject: email,
|
|
144
|
+
secret: await this.deps.passwordHasher.hash(input.password),
|
|
145
|
+
metadata: null,
|
|
146
|
+
createdAt: this.deps.clock.now(),
|
|
147
|
+
});
|
|
148
|
+
return user;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Verify an email/password login, returning the user on success or null on a bad
|
|
152
|
+
* email/password (the caller maps null → 401, never leaking which was wrong).
|
|
153
|
+
*/
|
|
154
|
+
async verifyPassword(input) {
|
|
155
|
+
const email = input.email.toLowerCase().trim();
|
|
156
|
+
const identity = await this.deps.userRepository.getIdentity('password', email);
|
|
157
|
+
if (!identity?.secret) {
|
|
158
|
+
// Equalise timing with the hit path so the response time can't reveal whether the
|
|
159
|
+
// email is registered (PBKDF2 against a dummy hash that can never match).
|
|
160
|
+
await this.deps.passwordHasher.verify(input.password, await dummyPasswordHash(this.deps.passwordHasher));
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (!(await this.deps.passwordHasher.verify(input.password, identity.secret)))
|
|
164
|
+
return null;
|
|
165
|
+
// Transparently upgrade a weaker-cost hash now that we hold the plaintext.
|
|
166
|
+
if (this.deps.passwordHasher.needsRehash(identity.secret)) {
|
|
167
|
+
await this.deps.userRepository.linkIdentity({
|
|
168
|
+
...identity,
|
|
169
|
+
secret: await this.deps.passwordHasher.hash(input.password),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return this.deps.userRepository.get(identity.userId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=UserService.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UserService.js","sourceRoot":"","sources":["../../../src/modules/users/UserService.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { CreateWorkspaceInput } from '@cat-factory/contracts';
|
|
2
|
+
import type { Workspace, WorkspaceSnapshot } from '@cat-factory/kernel';
|
|
3
|
+
import type { BlockRepository, ExecutionRepository, PipelineRepository, ServiceRepository, WorkspaceMountRepository, WorkspaceRepository, WorkspaceVisibility } from '@cat-factory/kernel';
|
|
4
|
+
import type { Clock, IdGenerator } from '@cat-factory/kernel';
|
|
5
|
+
export { requireWorkspace } from '@cat-factory/kernel';
|
|
6
|
+
export interface WorkspaceServiceDependencies {
|
|
7
|
+
workspaceRepository: WorkspaceRepository;
|
|
8
|
+
blockRepository: BlockRepository;
|
|
9
|
+
pipelineRepository: PipelineRepository;
|
|
10
|
+
executionRepository: ExecutionRepository;
|
|
11
|
+
idGenerator: IdGenerator;
|
|
12
|
+
clock: Clock;
|
|
13
|
+
/**
|
|
14
|
+
* In-org shared services. When wired, a board snapshot is composed from the services
|
|
15
|
+
* the workspace mounts: its own frames plus any service mounted from another
|
|
16
|
+
* workspace in the same org, with each frame's layout taken from its mount.
|
|
17
|
+
*/
|
|
18
|
+
serviceRepository?: ServiceRepository;
|
|
19
|
+
workspaceMountRepository?: WorkspaceMountRepository;
|
|
20
|
+
}
|
|
21
|
+
/** Creates, reads and deletes boards (workspaces) and assembles snapshots. */
|
|
22
|
+
export declare class WorkspaceService {
|
|
23
|
+
private readonly workspaceRepository;
|
|
24
|
+
private readonly blockRepository;
|
|
25
|
+
private readonly pipelineRepository;
|
|
26
|
+
private readonly executionRepository;
|
|
27
|
+
private readonly idGenerator;
|
|
28
|
+
private readonly clock;
|
|
29
|
+
private readonly serviceRepository?;
|
|
30
|
+
private readonly workspaceMountRepository?;
|
|
31
|
+
constructor({ workspaceRepository, blockRepository, pipelineRepository, executionRepository, idGenerator, clock, serviceRepository, workspaceMountRepository, }: WorkspaceServiceDependencies);
|
|
32
|
+
/**
|
|
33
|
+
* Boards visible to a user (see {@link WorkspaceVisibility}). A `null` scope
|
|
34
|
+
* means auth is disabled and all boards are returned.
|
|
35
|
+
*/
|
|
36
|
+
list(scope: WorkspaceVisibility): Promise<Workspace[]>;
|
|
37
|
+
/** Owning user id for a board (string/owned, null/none, undefined/missing). */
|
|
38
|
+
ownerOf(id: string): Promise<string | null | undefined>;
|
|
39
|
+
/** Owning account id for a board (string/scoped, null/legacy, undefined/missing). */
|
|
40
|
+
accountOf(id: string): Promise<string | null | undefined>;
|
|
41
|
+
create(input: CreateWorkspaceInput, ownerUserId: string | null, accountId: string | null): Promise<WorkspaceSnapshot>;
|
|
42
|
+
/**
|
|
43
|
+
* Seed the demo architecture, registering each top-level frame as an account-owned service
|
|
44
|
+
* (so seeded frames are shareable across the org exactly like ones created on the board) and
|
|
45
|
+
* stamping every seeded block with its frame's service. A no-op service registration when
|
|
46
|
+
* in-org sharing isn't wired leaves plain workspace-local blocks (legacy behaviour).
|
|
47
|
+
*/
|
|
48
|
+
private seedBoard;
|
|
49
|
+
/** Rename a board and/or update its description. */
|
|
50
|
+
update(id: string, patch: {
|
|
51
|
+
name?: string;
|
|
52
|
+
description?: string | null;
|
|
53
|
+
}): Promise<Workspace>;
|
|
54
|
+
require(id: string): Promise<Workspace>;
|
|
55
|
+
snapshot(id: string): Promise<WorkspaceSnapshot>;
|
|
56
|
+
/**
|
|
57
|
+
* Compose a workspace's board from the services it mounts: its own (locally created)
|
|
58
|
+
* blocks plus the full subtree of any service mounted from another workspace in the
|
|
59
|
+
* same org — so a shared service renders identically on every board, with one physical
|
|
60
|
+
* copy (and therefore one shared task list + status). Each mounted frame's board
|
|
61
|
+
* position/size is taken from the mount (the per-workspace layout override) — for a home
|
|
62
|
+
* frame as much as one mounted from elsewhere, since a service frame's position is always
|
|
63
|
+
* carried on the mount (that is what `moveBlock` writes). When the service repositories
|
|
64
|
+
* aren't wired (or nothing is mounted) this is a no-op and the local blocks stand.
|
|
65
|
+
*/
|
|
66
|
+
private composeBoard;
|
|
67
|
+
/**
|
|
68
|
+
* Compose a workspace's executions from the services it mounts: its own runs plus those of
|
|
69
|
+
* any service mounted from another workspace, so a shared service's run progress/status
|
|
70
|
+
* renders on every board that mounts it — not just on its home workspace. Deduplicated by
|
|
71
|
+
* run id (a home service's runs already appear in the local list). No-op when sharing isn't
|
|
72
|
+
* wired or nothing is mounted.
|
|
73
|
+
*/
|
|
74
|
+
private composeExecutions;
|
|
75
|
+
delete(id: string): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=WorkspaceService.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WorkspaceService.d.ts","sourceRoot":"","sources":["../../../src/modules/workspaces/WorkspaceService.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AAOlE,OAAO,KAAK,EAGV,SAAS,EAET,iBAAiB,EAClB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EACV,eAAe,EACf,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,EACjB,wBAAwB,EACxB,mBAAmB,EACnB,mBAAmB,EACpB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAE7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAEtD,MAAM,WAAW,4BAA4B;IAC3C,mBAAmB,EAAE,mBAAmB,CAAA;IACxC,eAAe,EAAE,eAAe,CAAA;IAChC,kBAAkB,EAAE,kBAAkB,CAAA;IACtC,mBAAmB,EAAE,mBAAmB,CAAA;IACxC,WAAW,EAAE,WAAW,CAAA;IACxB,KAAK,EAAE,KAAK,CAAA;IACZ;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC,wBAAwB,CAAC,EAAE,wBAAwB,CAAA;CACpD;AAED,8EAA8E;AAC9E,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IACzD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAiB;IACjD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAoB;IACvD,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IACzD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAa;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAO;IAC7B,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAmB;IACtD,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAC,CAA0B;IAEpE,YAAY,EACV,mBAAmB,EACnB,eAAe,EACf,kBAAkB,EAClB,mBAAmB,EACnB,WAAW,EACX,KAAK,EACL,iBAAiB,EACjB,wBAAwB,GACzB,EAAE,4BAA4B,EAS9B;IAED;;;OAGG;IACH,IAAI,CAAC,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAErD;IAED,+EAA+E;IAC/E,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAEtD;IAED,qFAAqF;IACrF,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAExD;IAEK,MAAM,CACV,KAAK,EAAE,oBAAoB,EAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,EAC1B,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,OAAO,CAAC,iBAAiB,CAAC,CAqB5B;IAED;;;;;OAKG;YACW,SAAS;IAmCvB,oDAAoD;IAC9C,MAAM,CACV,EAAE,EAAE,MAAM,EACV,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GACpD,OAAO,CAAC,SAAS,CAAC,CAQpB;IAED,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAEtC;IAEK,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAcrD;IAED;;;;;;;;;OASG;YACW,YAAY;IAqC1B;;;;;;OAMG;YACW,iBAAiB;IAazB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGtC;CACF"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { registerServiceForFrame, requireWorkspace, seedBlocks, seedPipelines, } from '@cat-factory/kernel';
|
|
2
|
+
export { requireWorkspace } from '@cat-factory/kernel';
|
|
3
|
+
/** Creates, reads and deletes boards (workspaces) and assembles snapshots. */
|
|
4
|
+
export class WorkspaceService {
|
|
5
|
+
workspaceRepository;
|
|
6
|
+
blockRepository;
|
|
7
|
+
pipelineRepository;
|
|
8
|
+
executionRepository;
|
|
9
|
+
idGenerator;
|
|
10
|
+
clock;
|
|
11
|
+
serviceRepository;
|
|
12
|
+
workspaceMountRepository;
|
|
13
|
+
constructor({ workspaceRepository, blockRepository, pipelineRepository, executionRepository, idGenerator, clock, serviceRepository, workspaceMountRepository, }) {
|
|
14
|
+
this.workspaceRepository = workspaceRepository;
|
|
15
|
+
this.blockRepository = blockRepository;
|
|
16
|
+
this.pipelineRepository = pipelineRepository;
|
|
17
|
+
this.executionRepository = executionRepository;
|
|
18
|
+
this.idGenerator = idGenerator;
|
|
19
|
+
this.clock = clock;
|
|
20
|
+
this.serviceRepository = serviceRepository;
|
|
21
|
+
this.workspaceMountRepository = workspaceMountRepository;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Boards visible to a user (see {@link WorkspaceVisibility}). A `null` scope
|
|
25
|
+
* means auth is disabled and all boards are returned.
|
|
26
|
+
*/
|
|
27
|
+
list(scope) {
|
|
28
|
+
return this.workspaceRepository.listVisible(scope);
|
|
29
|
+
}
|
|
30
|
+
/** Owning user id for a board (string/owned, null/none, undefined/missing). */
|
|
31
|
+
ownerOf(id) {
|
|
32
|
+
return this.workspaceRepository.ownerOf(id);
|
|
33
|
+
}
|
|
34
|
+
/** Owning account id for a board (string/scoped, null/legacy, undefined/missing). */
|
|
35
|
+
accountOf(id) {
|
|
36
|
+
return this.workspaceRepository.accountOf(id);
|
|
37
|
+
}
|
|
38
|
+
async create(input, ownerUserId, accountId) {
|
|
39
|
+
const workspace = {
|
|
40
|
+
id: this.idGenerator.next('ws'),
|
|
41
|
+
name: input.name?.trim() || 'Untitled board',
|
|
42
|
+
description: input.description?.trim() || null,
|
|
43
|
+
createdAt: this.clock.now(),
|
|
44
|
+
accountId,
|
|
45
|
+
};
|
|
46
|
+
await this.workspaceRepository.create(workspace, ownerUserId, accountId);
|
|
47
|
+
// The built-in pipeline catalog is product configuration, not sample data, so
|
|
48
|
+
// every board gets it — including the empty boards real users start with.
|
|
49
|
+
for (const pipeline of seedPipelines()) {
|
|
50
|
+
await this.pipelineRepository.insert(workspace.id, pipeline);
|
|
51
|
+
}
|
|
52
|
+
// The sample architecture blocks are opt-in (demo boards + the test fixtures);
|
|
53
|
+
// production boards start empty (the SPA creates with `seed: false`).
|
|
54
|
+
if (input.seed ?? true) {
|
|
55
|
+
await this.seedBoard(workspace.id);
|
|
56
|
+
}
|
|
57
|
+
return this.snapshot(workspace.id);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Seed the demo architecture, registering each top-level frame as an account-owned service
|
|
61
|
+
* (so seeded frames are shareable across the org exactly like ones created on the board) and
|
|
62
|
+
* stamping every seeded block with its frame's service. A no-op service registration when
|
|
63
|
+
* in-org sharing isn't wired leaves plain workspace-local blocks (legacy behaviour).
|
|
64
|
+
*/
|
|
65
|
+
async seedBoard(workspaceId) {
|
|
66
|
+
const blocks = seedBlocks();
|
|
67
|
+
const byId = new Map(blocks.map((b) => [b.id, b]));
|
|
68
|
+
const topFrameOf = (b) => {
|
|
69
|
+
let cur = b;
|
|
70
|
+
while (cur && !(cur.level === 'frame' && cur.parentId === null)) {
|
|
71
|
+
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
|
|
72
|
+
}
|
|
73
|
+
return cur;
|
|
74
|
+
};
|
|
75
|
+
const serviceByFrame = new Map();
|
|
76
|
+
for (const b of blocks) {
|
|
77
|
+
if (b.level === 'frame' && b.parentId === null) {
|
|
78
|
+
serviceByFrame.set(b.id, await registerServiceForFrame({
|
|
79
|
+
serviceRepository: this.serviceRepository,
|
|
80
|
+
workspaceMountRepository: this.workspaceMountRepository,
|
|
81
|
+
workspaceRepository: this.workspaceRepository,
|
|
82
|
+
idGenerator: this.idGenerator,
|
|
83
|
+
clock: this.clock,
|
|
84
|
+
}, workspaceId, b));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const b of blocks) {
|
|
88
|
+
const frame = topFrameOf(b);
|
|
89
|
+
await this.blockRepository.insert(workspaceId, b, frame ? serviceByFrame.get(frame.id) : null);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Rename a board and/or update its description. */
|
|
93
|
+
async update(id, patch) {
|
|
94
|
+
await this.require(id);
|
|
95
|
+
if (patch.name !== undefined)
|
|
96
|
+
await this.workspaceRepository.rename(id, patch.name.trim());
|
|
97
|
+
if ('description' in patch) {
|
|
98
|
+
const desc = patch.description == null ? null : patch.description.trim() || null;
|
|
99
|
+
await this.workspaceRepository.setDescription(id, desc);
|
|
100
|
+
}
|
|
101
|
+
return this.require(id);
|
|
102
|
+
}
|
|
103
|
+
require(id) {
|
|
104
|
+
return requireWorkspace(this.workspaceRepository, id);
|
|
105
|
+
}
|
|
106
|
+
async snapshot(id) {
|
|
107
|
+
const workspace = await this.require(id);
|
|
108
|
+
const [localBlocks, pipelines, localExecutions] = await Promise.all([
|
|
109
|
+
this.blockRepository.listByWorkspace(id),
|
|
110
|
+
this.pipelineRepository.listByWorkspace(id),
|
|
111
|
+
this.executionRepository.listByWorkspace(id),
|
|
112
|
+
]);
|
|
113
|
+
const mounts = this.workspaceMountRepository && this.serviceRepository
|
|
114
|
+
? await this.workspaceMountRepository.listByWorkspace(id)
|
|
115
|
+
: [];
|
|
116
|
+
const blocks = await this.composeBoard(localBlocks, mounts);
|
|
117
|
+
const executions = await this.composeExecutions(localExecutions, mounts);
|
|
118
|
+
return { workspace, blocks, pipelines, executions };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Compose a workspace's board from the services it mounts: its own (locally created)
|
|
122
|
+
* blocks plus the full subtree of any service mounted from another workspace in the
|
|
123
|
+
* same org — so a shared service renders identically on every board, with one physical
|
|
124
|
+
* copy (and therefore one shared task list + status). Each mounted frame's board
|
|
125
|
+
* position/size is taken from the mount (the per-workspace layout override) — for a home
|
|
126
|
+
* frame as much as one mounted from elsewhere, since a service frame's position is always
|
|
127
|
+
* carried on the mount (that is what `moveBlock` writes). When the service repositories
|
|
128
|
+
* aren't wired (or nothing is mounted) this is a no-op and the local blocks stand.
|
|
129
|
+
*/
|
|
130
|
+
async composeBoard(localBlocks, mounts) {
|
|
131
|
+
if (!this.serviceRepository || mounts.length === 0)
|
|
132
|
+
return localBlocks;
|
|
133
|
+
const byId = new Map(localBlocks.map((b) => [b.id, b]));
|
|
134
|
+
const localIds = new Set(byId.keys());
|
|
135
|
+
// The per-workspace layout override for each mounted service's frame.
|
|
136
|
+
const frameLayout = new Map();
|
|
137
|
+
// Resolve every mounted service in one batched query (not a `get` per mount).
|
|
138
|
+
const services = await this.serviceRepository.listByIds(mounts.map((m) => m.serviceId));
|
|
139
|
+
const frameOf = new Map(services.map((s) => [s.id, s.frameBlockId]));
|
|
140
|
+
const foreignServiceIds = [];
|
|
141
|
+
for (const mount of mounts) {
|
|
142
|
+
const frameBlockId = frameOf.get(mount.serviceId);
|
|
143
|
+
if (!frameBlockId)
|
|
144
|
+
continue;
|
|
145
|
+
frameLayout.set(frameBlockId, {
|
|
146
|
+
x: mount.position.x,
|
|
147
|
+
y: mount.position.y,
|
|
148
|
+
...(mount.size ? { w: mount.size.w, h: mount.size.h } : {}),
|
|
149
|
+
});
|
|
150
|
+
// Pull in the subtree only for services homed in ANOTHER workspace — a local service's
|
|
151
|
+
// blocks are already in `localBlocks`.
|
|
152
|
+
if (!localIds.has(frameBlockId))
|
|
153
|
+
foreignServiceIds.push(mount.serviceId);
|
|
154
|
+
}
|
|
155
|
+
// One batched query for all foreign subtrees (not one per service).
|
|
156
|
+
for (const b of await this.blockRepository.listByServices(foreignServiceIds)) {
|
|
157
|
+
if (!byId.has(b.id))
|
|
158
|
+
byId.set(b.id, b);
|
|
159
|
+
}
|
|
160
|
+
return [...byId.values()].map((b) => {
|
|
161
|
+
const layout = frameLayout.get(b.id);
|
|
162
|
+
if (!layout)
|
|
163
|
+
return b;
|
|
164
|
+
const next = { ...b, position: { x: layout.x, y: layout.y } };
|
|
165
|
+
if (layout.w !== undefined && layout.h !== undefined)
|
|
166
|
+
next.size = { w: layout.w, h: layout.h };
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Compose a workspace's executions from the services it mounts: its own runs plus those of
|
|
172
|
+
* any service mounted from another workspace, so a shared service's run progress/status
|
|
173
|
+
* renders on every board that mounts it — not just on its home workspace. Deduplicated by
|
|
174
|
+
* run id (a home service's runs already appear in the local list). No-op when sharing isn't
|
|
175
|
+
* wired or nothing is mounted.
|
|
176
|
+
*/
|
|
177
|
+
async composeExecutions(localExecutions, mounts) {
|
|
178
|
+
if (mounts.length === 0)
|
|
179
|
+
return localExecutions;
|
|
180
|
+
const byId = new Map(localExecutions.map((e) => [e.id, e]));
|
|
181
|
+
// One batched query for every mounted service's runs (not one round-trip per mount).
|
|
182
|
+
for (const e of await this.executionRepository.listByServices(mounts.map((m) => m.serviceId))) {
|
|
183
|
+
if (!byId.has(e.id))
|
|
184
|
+
byId.set(e.id, e);
|
|
185
|
+
}
|
|
186
|
+
return [...byId.values()];
|
|
187
|
+
}
|
|
188
|
+
async delete(id) {
|
|
189
|
+
await this.require(id);
|
|
190
|
+
await this.workspaceRepository.delete(id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=WorkspaceService.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WorkspaceService.js","sourceRoot":"","sources":["../../../src/modules/workspaces/WorkspaceService.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cat-factory/workspaces",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Tenancy base services (workspaces and accounts) for the Agent Architecture Board.",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist"
|
|
7
|
+
],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@cat-factory/contracts": "0.6.0",
|
|
23
|
+
"@cat-factory/kernel": "0.6.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "7.0.1-rc"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -b tsconfig.build.json",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
31
|
+
}
|
|
32
|
+
}
|