@noodleseed/one 0.161.2 → 0.162.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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/catalog.d.ts.map +1 -1
- package/dist/commands/catalog.js +1 -0
- package/dist/commands/catalog.js.map +1 -1
- package/dist/commands/solutions-ops.d.ts +7 -0
- package/dist/commands/solutions-ops.d.ts.map +1 -0
- package/dist/commands/solutions-ops.js +283 -0
- package/dist/commands/solutions-ops.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/curated/command-groups.js +4 -0
- package/node_modules/@noodle-borg/agent-kit/dist/curated/error-fixes.js +2 -1
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
- package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +3 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-refs.js +7 -0
- package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/authoring/dist/index.js +1 -0
- package/node_modules/@noodle-borg/authoring/dist/load-authored.js +1 -1
- package/node_modules/@noodle-borg/authoring/dist/managed-collection.d.ts +26 -0
- package/node_modules/@noodle-borg/authoring/dist/managed-collection.js +20 -0
- package/node_modules/@noodle-borg/authoring/dist/server.d.ts +3 -0
- package/node_modules/@noodle-borg/authoring/dist/server.js +2 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-tenant-resources.js +241 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/assemble.js +4 -1
- package/node_modules/@noodle-borg/compiler/dist/artifact/types.d.ts +10 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/version.d.ts +4 -1
- package/node_modules/@noodle-borg/compiler/dist/artifact/version.js +4 -1
- package/node_modules/@noodle-borg/compiler/dist/compile.js +3 -0
- package/node_modules/@noodle-borg/compiler/dist/errors.d.ts +1 -1
- package/node_modules/@noodle-borg/compiler/dist/index.d.ts +2 -1
- package/node_modules/@noodle-borg/compiler/dist/index.js +1 -0
- package/node_modules/@noodle-borg/compiler/dist/managed-collections.d.ts +25 -0
- package/node_modules/@noodle-borg/compiler/dist/managed-collections.js +184 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +14 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +3 -0
- package/node_modules/@noodle-borg/service/dist/business-information/contracts.js +5 -0
- package/node_modules/@noodle-borg/service/dist/business-information/in-memory-store.js +402 -0
- package/node_modules/@noodle-borg/service/dist/business-information/model.js +292 -0
- package/node_modules/@noodle-borg/service/dist/business-information/pagination.js +50 -0
- package/node_modules/@noodle-borg/service/dist/business-information/portable.js +6 -0
- package/node_modules/@noodle-borg/service/dist/business-information/profiles.js +121 -0
- package/node_modules/@noodle-borg/service/dist/business-information/validation.js +200 -0
- package/node_modules/@noodle-borg/service/dist/oauth/store.d.ts +2 -2
- package/node_modules/@noodle-borg/service/dist/routes/business-information-dispatch.js +48 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information-paths.js +92 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information-wire.js +117 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information.js +551 -0
- package/node_modules/@noodle-borg/service/dist/service-resource-cleanup.js +2 -0
- package/node_modules/@noodle-borg/service/dist/service.js +25 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/business-information.d.ts +583 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/business-information.js +205 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +4 -3
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/knowledge.d.ts +3 -3
- package/node_modules/@noodle-borg/wire-contracts/dist/managed-assistant-sponsorship.d.ts +8 -8
- package/package.json +1 -1
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { clientAddressBucket, } from '@noodle-borg/admission-limits/portable';
|
|
3
|
+
import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
|
|
4
|
+
import { BusinessGrantCreateRequestSchema, formatWireError, ManagedRecordCreateRequestSchema, ManagedRecordMutationRequestSchema, ManagedRecordStatusSchema, SolutionInstallationCreateRequestSchema, } from '@noodle-borg/wire-contracts';
|
|
5
|
+
import { BUILT_IN_SOLUTION_PROFILES, businessGrantAllows, CursorValidationError, PayloadValidationError, validateProfilePayload, } from '../business-information/portable.js';
|
|
6
|
+
import { sendForbidden } from '../http-util.js';
|
|
7
|
+
import { activityToWire, grantToWire, installationToWire, profileToWire, recordToWire, } from './business-information-wire.js';
|
|
8
|
+
import { authorizeControlPlane } from './control-plane.js';
|
|
9
|
+
import { canManageMembers } from './org-admin.js';
|
|
10
|
+
export function handleSolutionCatalog(req, res) {
|
|
11
|
+
if (req.method !== 'GET') {
|
|
12
|
+
methodNotAllowed(res);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
sendJson(res, 200, {
|
|
16
|
+
ok: true,
|
|
17
|
+
data: { profiles: Object.values(BUILT_IN_SOLUTION_PROFILES).map(profileToWire) },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function handleSolutionInstallations(req, res, ref, deps) {
|
|
21
|
+
const identity = await requireIdentity(req, res, deps);
|
|
22
|
+
if (identity === false)
|
|
23
|
+
return;
|
|
24
|
+
if (req.method === 'POST' && ref.installationId === undefined) {
|
|
25
|
+
if (!(await canManageMembers(deps.controlPlane, ref.org, identity))) {
|
|
26
|
+
return sendForbidden(res, 'organization owner required');
|
|
27
|
+
}
|
|
28
|
+
const body = await readJsonBody(req, deps.maxBody);
|
|
29
|
+
if (!body.ok)
|
|
30
|
+
return sendJson(res, body.status, { error: body.error });
|
|
31
|
+
const parsed = SolutionInstallationCreateRequestSchema.safeParse(body.value);
|
|
32
|
+
if (!parsed.success)
|
|
33
|
+
return invalid(res, parsed.error);
|
|
34
|
+
const profile = BUILT_IN_SOLUTION_PROFILES[parsed.data.profileId];
|
|
35
|
+
const installationId = stableInstallationId(ref.org, parsed.data.appSlug, parsed.data.environment);
|
|
36
|
+
const result = await deps.store.createInstallation({
|
|
37
|
+
scope: {
|
|
38
|
+
org: ref.org,
|
|
39
|
+
app: parsed.data.appSlug,
|
|
40
|
+
env: parsed.data.environment,
|
|
41
|
+
installationId,
|
|
42
|
+
},
|
|
43
|
+
profileKey: parsed.data.profileId,
|
|
44
|
+
managedCollections: profile.collections.map((collection) => collection.key),
|
|
45
|
+
retentionDays: parsed.data.retentionDays,
|
|
46
|
+
actorSubject: identity.subject,
|
|
47
|
+
actorEmail: identity.email,
|
|
48
|
+
});
|
|
49
|
+
if (result.disposition === 'conflict') {
|
|
50
|
+
return sendJson(res, 409, {
|
|
51
|
+
error: 'a different solution is already installed for this app and environment',
|
|
52
|
+
code: 'installation_conflict',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const grant = await deps.store.getGrant(result.installation.scope, identity.subject);
|
|
56
|
+
if (grant === undefined || grant.revokedAt !== undefined) {
|
|
57
|
+
return sendForbidden(res, 'business grant required');
|
|
58
|
+
}
|
|
59
|
+
return sendJson(res, result.disposition === 'created' ? 201 : 200, {
|
|
60
|
+
ok: true,
|
|
61
|
+
data: {
|
|
62
|
+
installation: installationToWire(result.installation, profile, grant.role),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (req.method === 'GET' && ref.installationId === undefined) {
|
|
67
|
+
const installations = await deps.store.listInstallations(ref.org);
|
|
68
|
+
const permitted = [];
|
|
69
|
+
for (const installation of installations) {
|
|
70
|
+
const grant = await deps.store.getGrant(installation.scope, identity.subject);
|
|
71
|
+
if (grant !== undefined && grant.revokedAt === undefined) {
|
|
72
|
+
permitted.push({ installation, role: grant.role });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return sendJson(res, 200, {
|
|
76
|
+
ok: true,
|
|
77
|
+
data: {
|
|
78
|
+
installations: permitted.map(({ installation, role }) => installationToWire(installation, BUILT_IN_SOLUTION_PROFILES[installation.profileKey], role)),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (req.method === 'GET' && ref.installationId !== undefined) {
|
|
83
|
+
const authorized = await requireInstallationGrant(res, ref, identity, deps);
|
|
84
|
+
if (authorized === undefined)
|
|
85
|
+
return;
|
|
86
|
+
return sendJson(res, 200, {
|
|
87
|
+
ok: true,
|
|
88
|
+
data: {
|
|
89
|
+
installation: installationToWire(authorized.installation, BUILT_IN_SOLUTION_PROFILES[authorized.installation.profileKey], authorized.grant.role),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return methodNotAllowed(res);
|
|
94
|
+
}
|
|
95
|
+
export async function handleBusinessGrants(req, res, ref, deps) {
|
|
96
|
+
const identity = await requireIdentity(req, res, deps);
|
|
97
|
+
if (identity === false)
|
|
98
|
+
return;
|
|
99
|
+
const authorized = await requireInstallationPermission(res, ref, identity, 'grants:manage', deps);
|
|
100
|
+
if (authorized === undefined)
|
|
101
|
+
return;
|
|
102
|
+
const { scope } = authorized.installation;
|
|
103
|
+
if (req.method === 'GET' && ref.subject === undefined) {
|
|
104
|
+
const grants = (await deps.store.listGrants(scope)).filter((grant) => grant.revokedAt === undefined);
|
|
105
|
+
return sendJson(res, 200, { ok: true, data: { grants: grants.map(grantToWire) } });
|
|
106
|
+
}
|
|
107
|
+
if (req.method === 'POST' && ref.subject === undefined) {
|
|
108
|
+
const body = await readJsonBody(req, deps.maxBody);
|
|
109
|
+
if (!body.ok)
|
|
110
|
+
return sendJson(res, body.status, { error: body.error });
|
|
111
|
+
const parsed = BusinessGrantCreateRequestSchema.safeParse(body.value);
|
|
112
|
+
if (!parsed.success)
|
|
113
|
+
return invalid(res, parsed.error);
|
|
114
|
+
const result = await deps.store.setGrant({
|
|
115
|
+
scope,
|
|
116
|
+
subject: parsed.data.subject,
|
|
117
|
+
email: parsed.data.email,
|
|
118
|
+
role: parsed.data.role,
|
|
119
|
+
expectedRevision: parsed.data.expectedRevision ?? 0,
|
|
120
|
+
actorSubject: identity.subject,
|
|
121
|
+
});
|
|
122
|
+
if (!result.ok)
|
|
123
|
+
return mutationFailure(res, result);
|
|
124
|
+
return sendJson(res, result.grant.revision === 1 ? 201 : 200, {
|
|
125
|
+
ok: true,
|
|
126
|
+
data: { grant: grantToWire(result.grant) },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (req.method === 'DELETE' && ref.subject !== undefined) {
|
|
130
|
+
const body = await readJsonBody(req, deps.maxBody);
|
|
131
|
+
if (!body.ok)
|
|
132
|
+
return sendJson(res, body.status, { error: body.error });
|
|
133
|
+
const revision = expectedRevision(body.value);
|
|
134
|
+
if (revision === undefined) {
|
|
135
|
+
return sendJson(res, 400, { error: 'expectedRevision must be a positive integer' });
|
|
136
|
+
}
|
|
137
|
+
const result = await deps.store.revokeGrant({
|
|
138
|
+
scope,
|
|
139
|
+
subject: ref.subject,
|
|
140
|
+
expectedRevision: revision,
|
|
141
|
+
actorSubject: identity.subject,
|
|
142
|
+
});
|
|
143
|
+
if (!result.ok)
|
|
144
|
+
return mutationFailure(res, result);
|
|
145
|
+
return sendJson(res, 200, { ok: true, data: { grant: grantToWire(result.grant) } });
|
|
146
|
+
}
|
|
147
|
+
return methodNotAllowed(res);
|
|
148
|
+
}
|
|
149
|
+
export async function handleManagedRecords(req, res, url, ref, deps) {
|
|
150
|
+
const identity = await requireIdentity(req, res, deps);
|
|
151
|
+
if (identity === false)
|
|
152
|
+
return;
|
|
153
|
+
if (ref.collection === undefined)
|
|
154
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
155
|
+
const collection = ref.collection;
|
|
156
|
+
if (ref.action === 'activity' && req.method === 'GET' && ref.recordId !== undefined) {
|
|
157
|
+
const authorized = await requireInstallationPermission(res, ref, identity, 'records:read', deps);
|
|
158
|
+
if (authorized === undefined)
|
|
159
|
+
return;
|
|
160
|
+
if (!collectionEnabled(res, authorized.installation, collection))
|
|
161
|
+
return;
|
|
162
|
+
const activities = await deps.store.listActivity(authorized.installation.scope, collection, ref.recordId);
|
|
163
|
+
return sendJson(res, 200, {
|
|
164
|
+
ok: true,
|
|
165
|
+
data: { activities: activities.map(activityToWire) },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (ref.action === 'export' && req.method === 'GET') {
|
|
169
|
+
const authorized = await requireInstallationPermission(res, ref, identity, 'records:export', deps);
|
|
170
|
+
if (authorized === undefined)
|
|
171
|
+
return;
|
|
172
|
+
if (!collectionEnabled(res, authorized.installation, collection))
|
|
173
|
+
return;
|
|
174
|
+
const paging = parsePaging(url, 500);
|
|
175
|
+
if (!paging.ok)
|
|
176
|
+
return sendJson(res, 400, { error: paging.error });
|
|
177
|
+
const page = await cursorRequest(res, () => deps.store.exportRequests({
|
|
178
|
+
scope: authorized.installation.scope,
|
|
179
|
+
collectionKey: collection,
|
|
180
|
+
...paging.value,
|
|
181
|
+
includeDeleted: url.searchParams.get('includeDeleted') === 'true',
|
|
182
|
+
}));
|
|
183
|
+
if (page === undefined)
|
|
184
|
+
return;
|
|
185
|
+
return sendJson(res, 200, {
|
|
186
|
+
ok: true,
|
|
187
|
+
data: {
|
|
188
|
+
records: page.records.map(recordToWire),
|
|
189
|
+
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (ref.recordId === undefined && req.method === 'GET') {
|
|
194
|
+
const authorized = await requireInstallationPermission(res, ref, identity, 'records:read', deps);
|
|
195
|
+
if (authorized === undefined)
|
|
196
|
+
return;
|
|
197
|
+
if (!collectionEnabled(res, authorized.installation, collection))
|
|
198
|
+
return;
|
|
199
|
+
const paging = parsePaging(url, 100);
|
|
200
|
+
if (!paging.ok)
|
|
201
|
+
return sendJson(res, 400, { error: paging.error });
|
|
202
|
+
const statusValue = url.searchParams.get('status');
|
|
203
|
+
const status = statusValue === null ? undefined : ManagedRecordStatusSchema.safeParse(statusValue);
|
|
204
|
+
if (status !== undefined && !status.success)
|
|
205
|
+
return sendJson(res, 400, { error: 'invalid status' });
|
|
206
|
+
const assigneeSubject = url.searchParams.get('assigneeSubject') ?? undefined;
|
|
207
|
+
const page = await cursorRequest(res, () => deps.store.listRequests({
|
|
208
|
+
scope: authorized.installation.scope,
|
|
209
|
+
collectionKey: collection,
|
|
210
|
+
...paging.value,
|
|
211
|
+
...(status === undefined ? {} : { status: status.data }),
|
|
212
|
+
...(assigneeSubject === undefined ? {} : { assigneeSubject }),
|
|
213
|
+
includeDeleted: url.searchParams.get('includeDeleted') === 'true',
|
|
214
|
+
}));
|
|
215
|
+
if (page === undefined)
|
|
216
|
+
return;
|
|
217
|
+
return sendJson(res, 200, {
|
|
218
|
+
ok: true,
|
|
219
|
+
data: {
|
|
220
|
+
records: page.records.map(recordToWire),
|
|
221
|
+
...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (ref.recordId === undefined && req.method === 'POST') {
|
|
226
|
+
const authorized = await requireInstallationPermission(res, ref, identity, 'records:create', deps);
|
|
227
|
+
if (authorized === undefined)
|
|
228
|
+
return;
|
|
229
|
+
if (!collectionEnabled(res, authorized.installation, collection))
|
|
230
|
+
return;
|
|
231
|
+
const idempotencyKey = requestIdempotencyKey(req);
|
|
232
|
+
if (idempotencyKey === undefined) {
|
|
233
|
+
return sendJson(res, 400, { error: 'Idempotency-Key header is required (1-128 characters)' });
|
|
234
|
+
}
|
|
235
|
+
const body = await readJsonBody(req, deps.maxBody);
|
|
236
|
+
if (!body.ok)
|
|
237
|
+
return sendJson(res, body.status, { error: body.error });
|
|
238
|
+
const parsed = ManagedRecordCreateRequestSchema.safeParse(body.value);
|
|
239
|
+
if (!parsed.success)
|
|
240
|
+
return invalid(res, parsed.error);
|
|
241
|
+
const payload = profilePayload(res, authorized.installation, collection, parsed.data.payload);
|
|
242
|
+
if (payload === undefined)
|
|
243
|
+
return;
|
|
244
|
+
const result = await deps.store.createRequest({
|
|
245
|
+
scope: authorized.installation.scope,
|
|
246
|
+
collectionKey: collection,
|
|
247
|
+
idempotencyKey,
|
|
248
|
+
payload,
|
|
249
|
+
origin: { kind: 'portal' },
|
|
250
|
+
actorSubject: identity.subject,
|
|
251
|
+
});
|
|
252
|
+
if (result.disposition === 'conflict')
|
|
253
|
+
return idempotencyConflict(res);
|
|
254
|
+
return sendJson(res, result.disposition === 'created' ? 201 : 200, {
|
|
255
|
+
ok: true,
|
|
256
|
+
data: { record: recordToWire(result.record) },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (ref.recordId !== undefined && req.method === 'GET') {
|
|
260
|
+
const authorized = await requireInstallationPermission(res, ref, identity, 'records:read', deps);
|
|
261
|
+
if (authorized === undefined)
|
|
262
|
+
return;
|
|
263
|
+
if (!collectionEnabled(res, authorized.installation, collection))
|
|
264
|
+
return;
|
|
265
|
+
const record = await deps.store.getRequest(authorized.installation.scope, collection, ref.recordId);
|
|
266
|
+
if (record === undefined)
|
|
267
|
+
return sendJson(res, 404, { error: 'record not found' });
|
|
268
|
+
return sendJson(res, 200, { ok: true, data: { record: recordToWire(record) } });
|
|
269
|
+
}
|
|
270
|
+
if (ref.recordId !== undefined && req.method === 'PATCH') {
|
|
271
|
+
const body = await readJsonBody(req, deps.maxBody);
|
|
272
|
+
if (!body.ok)
|
|
273
|
+
return sendJson(res, body.status, { error: body.error });
|
|
274
|
+
const parsed = ManagedRecordMutationRequestSchema.safeParse(body.value);
|
|
275
|
+
if (!parsed.success)
|
|
276
|
+
return invalid(res, parsed.error);
|
|
277
|
+
const permission = mutationPermission(parsed.data.operation);
|
|
278
|
+
const authorized = await requireInstallationPermission(res, ref, identity, permission, deps);
|
|
279
|
+
if (authorized === undefined)
|
|
280
|
+
return;
|
|
281
|
+
if (!collectionEnabled(res, authorized.installation, collection))
|
|
282
|
+
return;
|
|
283
|
+
let operation;
|
|
284
|
+
if (parsed.data.operation === 'update') {
|
|
285
|
+
const current = await deps.store.getRequest(authorized.installation.scope, collection, ref.recordId);
|
|
286
|
+
if (current?.content === undefined)
|
|
287
|
+
return sendJson(res, 404, { error: 'record not found' });
|
|
288
|
+
const payload = profilePayload(res, authorized.installation, collection, {
|
|
289
|
+
...current.content.payload,
|
|
290
|
+
...parsed.data.patch,
|
|
291
|
+
});
|
|
292
|
+
if (payload === undefined)
|
|
293
|
+
return;
|
|
294
|
+
operation = { kind: 'update', payload };
|
|
295
|
+
}
|
|
296
|
+
else if (parsed.data.operation === 'assign') {
|
|
297
|
+
if (parsed.data.assigneeSubject !== null) {
|
|
298
|
+
const assigneeGrant = await deps.store.getGrant(authorized.installation.scope, parsed.data.assigneeSubject);
|
|
299
|
+
if (assigneeGrant === undefined ||
|
|
300
|
+
assigneeGrant.revokedAt !== undefined ||
|
|
301
|
+
assigneeGrant.role === 'viewer') {
|
|
302
|
+
return sendJson(res, 422, {
|
|
303
|
+
error: 'assignee must have an active operator grant',
|
|
304
|
+
code: 'invalid_assignee',
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
operation = {
|
|
309
|
+
kind: 'assign',
|
|
310
|
+
assigneeSubject: parsed.data.assigneeSubject ?? undefined,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
else if (parsed.data.operation === 'set-status') {
|
|
314
|
+
operation = { kind: 'set_status', status: parsed.data.status };
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
operation = { kind: 'add_note', note: parsed.data.note };
|
|
318
|
+
}
|
|
319
|
+
const result = await deps.store.mutateRequest({
|
|
320
|
+
scope: authorized.installation.scope,
|
|
321
|
+
collectionKey: collection,
|
|
322
|
+
id: ref.recordId,
|
|
323
|
+
expectedRevision: parsed.data.expectedRevision,
|
|
324
|
+
actorSubject: identity.subject,
|
|
325
|
+
operation,
|
|
326
|
+
});
|
|
327
|
+
if (!result.ok)
|
|
328
|
+
return mutationFailure(res, result);
|
|
329
|
+
return sendJson(res, 200, { ok: true, data: { record: recordToWire(result.record) } });
|
|
330
|
+
}
|
|
331
|
+
if (ref.recordId !== undefined && req.method === 'DELETE') {
|
|
332
|
+
const authorized = await requireInstallationPermission(res, ref, identity, 'records:delete', deps);
|
|
333
|
+
if (authorized === undefined)
|
|
334
|
+
return;
|
|
335
|
+
if (!collectionEnabled(res, authorized.installation, collection))
|
|
336
|
+
return;
|
|
337
|
+
const body = await readJsonBody(req, deps.maxBody);
|
|
338
|
+
if (!body.ok)
|
|
339
|
+
return sendJson(res, body.status, { error: body.error });
|
|
340
|
+
const revision = expectedRevision(body.value);
|
|
341
|
+
if (revision === undefined) {
|
|
342
|
+
return sendJson(res, 400, { error: 'expectedRevision must be a positive integer' });
|
|
343
|
+
}
|
|
344
|
+
const result = await deps.store.deleteRequest({
|
|
345
|
+
scope: authorized.installation.scope,
|
|
346
|
+
collectionKey: collection,
|
|
347
|
+
id: ref.recordId,
|
|
348
|
+
expectedRevision: revision,
|
|
349
|
+
actorSubject: identity.subject,
|
|
350
|
+
reason: 'customer_request',
|
|
351
|
+
});
|
|
352
|
+
if (!result.ok)
|
|
353
|
+
return mutationFailure(res, result);
|
|
354
|
+
return sendJson(res, 200, {
|
|
355
|
+
ok: true,
|
|
356
|
+
data: { recordId: result.record.id, deletedAt: result.record.deletedAt },
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return methodNotAllowed(res);
|
|
360
|
+
}
|
|
361
|
+
export async function handlePublicSolutionIntake(req, res, ref, deps) {
|
|
362
|
+
const installation = await deps.store.resolveInstallationByPublicId(ref.publicId);
|
|
363
|
+
if (installation === undefined)
|
|
364
|
+
return sendJson(res, 404, { error: 'solution not found' });
|
|
365
|
+
const profile = BUILT_IN_SOLUTION_PROFILES[installation.profileKey];
|
|
366
|
+
if (req.method === 'GET' && ref.collection === undefined) {
|
|
367
|
+
const wire = profileToWire(profile);
|
|
368
|
+
return sendJson(res, 200, {
|
|
369
|
+
ok: true,
|
|
370
|
+
data: {
|
|
371
|
+
publicId: installation.publicId,
|
|
372
|
+
title: wire.title,
|
|
373
|
+
description: wire.description,
|
|
374
|
+
collection: wire.collection,
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (req.method === 'POST' && ref.collection !== undefined) {
|
|
379
|
+
if (!collectionEnabled(res, installation, ref.collection))
|
|
380
|
+
return;
|
|
381
|
+
const quota = await consumePublicIntakeQuota(req, ref.publicId, deps);
|
|
382
|
+
if (!quota.allowed) {
|
|
383
|
+
res.setHeader('retry-after', String(Math.max(1, Math.ceil((quota.resetAt.getTime() - now(deps).getTime()) / 1000))));
|
|
384
|
+
return sendJson(res, 429, { error: 'public intake limit exceeded', code: 'quota_exceeded' });
|
|
385
|
+
}
|
|
386
|
+
const idempotencyKey = requestIdempotencyKey(req);
|
|
387
|
+
if (idempotencyKey === undefined) {
|
|
388
|
+
return sendJson(res, 400, { error: 'Idempotency-Key header is required (1-128 characters)' });
|
|
389
|
+
}
|
|
390
|
+
const body = await readJsonBody(req, deps.maxBody);
|
|
391
|
+
if (!body.ok)
|
|
392
|
+
return sendJson(res, body.status, { error: body.error });
|
|
393
|
+
const parsed = ManagedRecordCreateRequestSchema.safeParse(body.value);
|
|
394
|
+
if (!parsed.success)
|
|
395
|
+
return invalid(res, parsed.error);
|
|
396
|
+
const payload = profilePayload(res, installation, ref.collection, parsed.data.payload);
|
|
397
|
+
if (payload === undefined)
|
|
398
|
+
return;
|
|
399
|
+
const result = await deps.store.createRequest({
|
|
400
|
+
scope: installation.scope,
|
|
401
|
+
collectionKey: ref.collection,
|
|
402
|
+
idempotencyKey,
|
|
403
|
+
payload,
|
|
404
|
+
origin: { kind: 'embedded', reference: 'public-intake' },
|
|
405
|
+
actorSubject: 'anonymous',
|
|
406
|
+
});
|
|
407
|
+
if (result.disposition === 'conflict')
|
|
408
|
+
return idempotencyConflict(res);
|
|
409
|
+
return sendJson(res, result.disposition === 'created' ? 201 : 200, {
|
|
410
|
+
ok: true,
|
|
411
|
+
data: { recordId: result.record.id, receivedAt: result.record.createdAt },
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return methodNotAllowed(res);
|
|
415
|
+
}
|
|
416
|
+
async function requireIdentity(req, res, deps) {
|
|
417
|
+
return authorizeControlPlane(req, res, deps.gate, { requireIdentity: true });
|
|
418
|
+
}
|
|
419
|
+
async function requireInstallationGrant(res, ref, identity, deps) {
|
|
420
|
+
if (ref.installationId === undefined)
|
|
421
|
+
return undefined;
|
|
422
|
+
const installation = await deps.store.getInstallationById(ref.org, ref.installationId);
|
|
423
|
+
if (installation === undefined) {
|
|
424
|
+
sendJson(res, 404, { error: 'installation not found' });
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
const grant = await deps.store.getGrant(installation.scope, identity.subject);
|
|
428
|
+
if (grant?.revokedAt !== undefined || grant === undefined) {
|
|
429
|
+
sendForbidden(res, 'business grant required');
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
return { installation, scope: installation.scope, grant };
|
|
433
|
+
}
|
|
434
|
+
async function requireInstallationPermission(res, ref, identity, permission, deps) {
|
|
435
|
+
const authorized = await requireInstallationGrant(res, ref, identity, deps);
|
|
436
|
+
if (authorized === undefined)
|
|
437
|
+
return undefined;
|
|
438
|
+
if (!businessGrantAllows(authorized.grant, permission)) {
|
|
439
|
+
sendForbidden(res, `business permission required: ${permission}`);
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
return authorized;
|
|
443
|
+
}
|
|
444
|
+
function stableInstallationId(org, app, env) {
|
|
445
|
+
return `ins-${createHash('sha256').update(`${org}\0${app}\0${env}`).digest('hex').slice(0, 24)}`;
|
|
446
|
+
}
|
|
447
|
+
function requestIdempotencyKey(req) {
|
|
448
|
+
const value = req.headers['idempotency-key'];
|
|
449
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 128)
|
|
450
|
+
return undefined;
|
|
451
|
+
return value;
|
|
452
|
+
}
|
|
453
|
+
function expectedRevision(value) {
|
|
454
|
+
if (value === null || typeof value !== 'object')
|
|
455
|
+
return undefined;
|
|
456
|
+
const revision = value.expectedRevision;
|
|
457
|
+
return typeof revision === 'number' && Number.isInteger(revision) && revision > 0
|
|
458
|
+
? revision
|
|
459
|
+
: undefined;
|
|
460
|
+
}
|
|
461
|
+
function parsePaging(url, maximum) {
|
|
462
|
+
const cursor = url.searchParams.get('cursor') ?? undefined;
|
|
463
|
+
const rawLimit = url.searchParams.get('limit');
|
|
464
|
+
if (rawLimit === null)
|
|
465
|
+
return { ok: true, value: cursor === undefined ? {} : { cursor } };
|
|
466
|
+
const limit = Number(rawLimit);
|
|
467
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > maximum) {
|
|
468
|
+
return { ok: false, error: `limit must be an integer from 1 to ${maximum}` };
|
|
469
|
+
}
|
|
470
|
+
return { ok: true, value: { ...(cursor === undefined ? {} : { cursor }), limit } };
|
|
471
|
+
}
|
|
472
|
+
function mutationPermission(operation) {
|
|
473
|
+
if (operation === 'assign')
|
|
474
|
+
return 'records:assign';
|
|
475
|
+
if (operation === 'set-status')
|
|
476
|
+
return 'records:status';
|
|
477
|
+
if (operation === 'add-note')
|
|
478
|
+
return 'records:note';
|
|
479
|
+
return 'records:update';
|
|
480
|
+
}
|
|
481
|
+
function mutationFailure(res, result) {
|
|
482
|
+
const status = result.reason === 'not_found'
|
|
483
|
+
? 404
|
|
484
|
+
: result.reason === 'conflict' || result.reason === 'last_administrator'
|
|
485
|
+
? 409
|
|
486
|
+
: 422;
|
|
487
|
+
sendJson(res, status, {
|
|
488
|
+
error: result.reason.replaceAll('_', ' '),
|
|
489
|
+
code: result.reason,
|
|
490
|
+
currentRevision: result.currentRevision,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
function collectionEnabled(res, installation, collection) {
|
|
494
|
+
if (installation.managedCollections.includes(collection))
|
|
495
|
+
return true;
|
|
496
|
+
sendJson(res, 404, { error: 'collection not found' });
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
function profilePayload(res, installation, collection, value) {
|
|
500
|
+
try {
|
|
501
|
+
return validateProfilePayload(installation.profileKey, collection, value);
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
if (!(error instanceof PayloadValidationError))
|
|
505
|
+
throw error;
|
|
506
|
+
sendJson(res, 400, { error: error.message, code: error.code });
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async function cursorRequest(res, request) {
|
|
511
|
+
try {
|
|
512
|
+
return await request();
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
if (!(error instanceof CursorValidationError))
|
|
516
|
+
throw error;
|
|
517
|
+
sendJson(res, 400, { error: 'cursor is invalid', code: 'invalid_cursor' });
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async function consumePublicIntakeQuota(req, publicId, deps) {
|
|
522
|
+
const timestamp = now(deps);
|
|
523
|
+
const address = deps.trustProxy
|
|
524
|
+
? typeof req.headers['x-forwarded-for'] === 'string'
|
|
525
|
+
? req.headers['x-forwarded-for']
|
|
526
|
+
: undefined
|
|
527
|
+
: req.socket.remoteAddress;
|
|
528
|
+
const bucket = clientAddressBucket(address);
|
|
529
|
+
if (bucket !== undefined) {
|
|
530
|
+
const addressOutcome = await deps.publicCounters.consume({ key: `solution-intake:address:${publicId}:${bucket}`, limit: 60, window: 'hour' }, timestamp);
|
|
531
|
+
if (!addressOutcome.allowed)
|
|
532
|
+
return addressOutcome;
|
|
533
|
+
}
|
|
534
|
+
return deps.publicCounters.consume({ key: `solution-intake:surface:${publicId}`, limit: 10_000, window: 'day' }, timestamp);
|
|
535
|
+
}
|
|
536
|
+
function now(deps) {
|
|
537
|
+
return deps.now?.() ?? new Date();
|
|
538
|
+
}
|
|
539
|
+
function invalid(res, error) {
|
|
540
|
+
sendJson(res, 400, { error: formatWireError(error) });
|
|
541
|
+
}
|
|
542
|
+
function idempotencyConflict(res) {
|
|
543
|
+
sendJson(res, 409, {
|
|
544
|
+
error: 'the idempotency key was already used with different content',
|
|
545
|
+
code: 'idempotency_conflict',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
function methodNotAllowed(res) {
|
|
549
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
550
|
+
}
|
|
551
|
+
//# sourceMappingURL=business-information.js.map
|
|
@@ -4,6 +4,8 @@ export async function closeServiceResources(input) {
|
|
|
4
4
|
clearInterval(input.welcomeEmailTimer);
|
|
5
5
|
if (input.feedbackOperationsTimer !== undefined)
|
|
6
6
|
clearInterval(input.feedbackOperationsTimer);
|
|
7
|
+
if (input.businessInformationTimer !== undefined)
|
|
8
|
+
clearInterval(input.businessInformationTimer);
|
|
7
9
|
clearInterval(input.alertTimer);
|
|
8
10
|
const errors = [];
|
|
9
11
|
const moduleHost = input.moduleHost;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { InMemoryDailyCounterStore } from '@noodle-borg/admission-limits/portable';
|
|
1
2
|
import { InMemoryAssistantAppearanceSettingsStore, InMemoryAssistantStore, } from '@noodle-borg/assistant-gateway/portable';
|
|
2
3
|
import { allowAllGate, InMemoryControlPlaneStore } from '@noodle-borg/control-plane/portable';
|
|
3
4
|
import { dispatchKnowledgeRequest } from '@noodle-borg/knowledge-operations/portable';
|
|
@@ -8,6 +9,7 @@ import { createArchivePreflight } from './archive-preflight.js';
|
|
|
8
9
|
import { ArchiveSweeper, resolveArchiveRetentionDays } from './archive-sweeper.js';
|
|
9
10
|
import { serveLocalAsset } from './assets.js';
|
|
10
11
|
import { resolveBuildInfo, serviceInfoPayload } from './build-info.js';
|
|
12
|
+
import { InMemoryBusinessInformationStore } from './business-information/portable.js';
|
|
11
13
|
import { serviceCapabilityReport } from './capabilities.js';
|
|
12
14
|
import { rejectIncompatibleCli } from './client-compatibility.js';
|
|
13
15
|
import { createDataPlaneMembershipAuthorizer } from './data-plane-membership.js';
|
|
@@ -28,6 +30,7 @@ import { handleAppArchive, handleAppRestore } from './routes/archive.js';
|
|
|
28
30
|
import { handleAssetPreflight } from './routes/asset-control-plane.js';
|
|
29
31
|
import { dispatchAssistantRoutes } from './routes/assistant-dispatch.js';
|
|
30
32
|
import { dispatchAuthDiscoveryRoute } from './routes/auth-discovery.js';
|
|
33
|
+
import { dispatchBusinessInformationRoutes } from './routes/business-information-dispatch.js';
|
|
31
34
|
import { dispatchConfigValueRequest } from './routes/config-values-dispatch.js';
|
|
32
35
|
import { authorizeTenantControl, handleDeploymentStatus, handleWhoami, } from './routes/control-plane.js';
|
|
33
36
|
import { dispatchCredentialAuthRoutes } from './routes/credential-auth-dispatch.js';
|
|
@@ -62,6 +65,11 @@ export function createServiceHandler(registry, options = {}) {
|
|
|
62
65
|
const assistantStore = options.assistantStore ?? new InMemoryAssistantStore();
|
|
63
66
|
const assistantAppearance = options.assistantAppearance ?? new InMemoryAssistantAppearanceSettingsStore();
|
|
64
67
|
const anonymousLimiter = new AnonymousConsumerLimiter();
|
|
68
|
+
const publicCounters = options.admissionCounters ?? new InMemoryDailyCounterStore();
|
|
69
|
+
const businessInformationStore = options.businessInformationStore ??
|
|
70
|
+
(options.businessInformationEnabled === false
|
|
71
|
+
? undefined
|
|
72
|
+
: new InMemoryBusinessInformationStore());
|
|
65
73
|
registry.setPlatformConnectors({ catalog: [BUILTIN_STATE_CATALOG_CONNECTOR], connectors: [] });
|
|
66
74
|
const moduleProviders = createServiceModuleProviders({
|
|
67
75
|
options,
|
|
@@ -256,6 +264,23 @@ export function createServiceHandler(registry, options = {}) {
|
|
|
256
264
|
}
|
|
257
265
|
if (servicePrincipalDispatcher?.(req, res, url))
|
|
258
266
|
return;
|
|
267
|
+
if (businessInformationStore !== undefined &&
|
|
268
|
+
options.businessInformationEnabled !== false &&
|
|
269
|
+
dispatchBusinessInformationRoutes(req, res, url, {
|
|
270
|
+
store: businessInformationStore,
|
|
271
|
+
gate,
|
|
272
|
+
controlPlane,
|
|
273
|
+
maxBody,
|
|
274
|
+
publicCounters,
|
|
275
|
+
trustProxy: tls.trustProxy === true,
|
|
276
|
+
...(options.clock === undefined ? {} : { now: options.clock }),
|
|
277
|
+
logger,
|
|
278
|
+
tls,
|
|
279
|
+
applySecurityHeaders,
|
|
280
|
+
enforceHttps,
|
|
281
|
+
})) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
259
284
|
if (dispatchAssistantRoutes(req, res, url, {
|
|
260
285
|
registry,
|
|
261
286
|
store: assistantStore,
|