@noodleseed/one 0.161.1 → 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/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
- 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/service/package.json +1 -1
- 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/node_modules/@noodleseed/assistant/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { canonicalJson, sha256Canonical } from '@noodle-borg/app-package';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export const MAX_MANAGED_COLLECTIONS = 16;
|
|
4
|
+
export const MAX_MANAGED_COLLECTION_NAME_LENGTH = 64;
|
|
5
|
+
export const MAX_MANAGED_COLLECTION_TITLE_LENGTH = 120;
|
|
6
|
+
export const MAX_MANAGED_COLLECTION_DESCRIPTION_LENGTH = 1_000;
|
|
7
|
+
export const MAX_MANAGED_COLLECTION_SCHEMA_VERSION = 2_147_483_647;
|
|
8
|
+
export const MAX_MANAGED_COLLECTION_RECORD_SCHEMA_BYTES = 64 * 1024;
|
|
9
|
+
export const MAX_MANAGED_COLLECTION_RECORD_SCHEMA_DEPTH = 12;
|
|
10
|
+
export const MAX_MANAGED_COLLECTION_RECORD_FIELDS = 128;
|
|
11
|
+
const collectionNameSchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(MAX_MANAGED_COLLECTION_NAME_LENGTH)
|
|
15
|
+
.regex(/^[a-z][a-z0-9_]*$/, 'must start with a letter and use lowercase letters, numbers, and underscores');
|
|
16
|
+
const jsonSchemaSchema = z.record(z.string(), z.unknown());
|
|
17
|
+
export const managedCollectionManifestSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
name: collectionNameSchema,
|
|
20
|
+
title: z.string().trim().min(1).max(MAX_MANAGED_COLLECTION_TITLE_LENGTH),
|
|
21
|
+
description: z.string().trim().min(1).max(MAX_MANAGED_COLLECTION_DESCRIPTION_LENGTH),
|
|
22
|
+
schemaVersion: z.number().int().positive().max(MAX_MANAGED_COLLECTION_SCHEMA_VERSION),
|
|
23
|
+
recordSchema: jsonSchemaSchema,
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
const PROHIBITED_FIELD_NAME = /(?:card|cvv|cvc|password|passcode|token|secret|passport|governmentid|nationalid|socialsecurity|health|biometric)/i;
|
|
27
|
+
const SUBSCHEMA_MAP_KEYS = [
|
|
28
|
+
'properties',
|
|
29
|
+
'patternProperties',
|
|
30
|
+
'$defs',
|
|
31
|
+
'dependentSchemas',
|
|
32
|
+
];
|
|
33
|
+
const SUBSCHEMA_KEYS = [
|
|
34
|
+
'items',
|
|
35
|
+
'additionalProperties',
|
|
36
|
+
'propertyNames',
|
|
37
|
+
'not',
|
|
38
|
+
'contains',
|
|
39
|
+
'if',
|
|
40
|
+
'then',
|
|
41
|
+
'else',
|
|
42
|
+
];
|
|
43
|
+
const SUBSCHEMA_LIST_KEYS = ['prefixItems', 'allOf', 'anyOf', 'oneOf'];
|
|
44
|
+
/**
|
|
45
|
+
* Validate and lower reusable collection intent. Operator lifecycle, grants, residency, and
|
|
46
|
+
* retention deliberately do not enter this compiled definition.
|
|
47
|
+
*/
|
|
48
|
+
export function compileManagedCollections(collections, toolNames, errors) {
|
|
49
|
+
const compiled = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
collections.forEach((collection, index) => {
|
|
52
|
+
const path = `server.collections.${index}`;
|
|
53
|
+
if (seen.has(collection.name)) {
|
|
54
|
+
errors.push({
|
|
55
|
+
code: 'invalid_managed_collection',
|
|
56
|
+
path: `${path}.name`,
|
|
57
|
+
message: `duplicate managed collection name "${collection.name}"`,
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
seen.add(collection.name);
|
|
62
|
+
const futureToolName = `submit_${collection.name}`;
|
|
63
|
+
const collision = toolNames.indexOf(futureToolName);
|
|
64
|
+
if (collision >= 0) {
|
|
65
|
+
errors.push({
|
|
66
|
+
code: 'reserved_name',
|
|
67
|
+
path: `tools.${collision}.name`,
|
|
68
|
+
message: `tool name "${futureToolName}" is reserved for managed collection "${collection.name}"`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const schemaErrorsBefore = errors.length;
|
|
72
|
+
validateRecordSchema(collection.recordSchema, `${path}.recordSchema`, errors);
|
|
73
|
+
if (errors.length !== schemaErrorsBefore)
|
|
74
|
+
return;
|
|
75
|
+
compiled.push({
|
|
76
|
+
name: collection.name,
|
|
77
|
+
title: collection.title,
|
|
78
|
+
description: collection.description,
|
|
79
|
+
schemaVersion: collection.schemaVersion,
|
|
80
|
+
schemaDigest: sha256Canonical(collection.recordSchema),
|
|
81
|
+
recordSchema: collection.recordSchema,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return compiled;
|
|
85
|
+
}
|
|
86
|
+
function validateRecordSchema(schema, path, errors) {
|
|
87
|
+
const bytes = new TextEncoder().encode(canonicalJson(schema)).byteLength;
|
|
88
|
+
if (bytes > MAX_MANAGED_COLLECTION_RECORD_SCHEMA_BYTES) {
|
|
89
|
+
errors.push({
|
|
90
|
+
code: 'invalid_managed_collection',
|
|
91
|
+
path,
|
|
92
|
+
message: `record schema is ${bytes} UTF-8 bytes; the limit is ${MAX_MANAGED_COLLECTION_RECORD_SCHEMA_BYTES} bytes`,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (schema.type !== 'object' || schema.additionalProperties !== false) {
|
|
97
|
+
errors.push({
|
|
98
|
+
code: 'invalid_managed_collection',
|
|
99
|
+
path,
|
|
100
|
+
message: 'record schema must be a closed object with additionalProperties false',
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const state = { fields: 0, failure: undefined };
|
|
105
|
+
inspectSchema(schema, path, 1, state);
|
|
106
|
+
if (state.failure !== undefined)
|
|
107
|
+
errors.push(state.failure);
|
|
108
|
+
}
|
|
109
|
+
function inspectSchema(node, path, depth, state) {
|
|
110
|
+
if (state.failure !== undefined ||
|
|
111
|
+
node === null ||
|
|
112
|
+
typeof node !== 'object' ||
|
|
113
|
+
Array.isArray(node)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (depth > MAX_MANAGED_COLLECTION_RECORD_SCHEMA_DEPTH) {
|
|
117
|
+
state.failure = {
|
|
118
|
+
code: 'invalid_managed_collection',
|
|
119
|
+
path,
|
|
120
|
+
message: `record schema depth exceeds ${MAX_MANAGED_COLLECTION_RECORD_SCHEMA_DEPTH}`,
|
|
121
|
+
};
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const schema = node;
|
|
125
|
+
if (schema.type === 'object' && schema.additionalProperties !== false) {
|
|
126
|
+
state.failure = {
|
|
127
|
+
code: 'invalid_managed_collection',
|
|
128
|
+
path,
|
|
129
|
+
message: 'every record object must set additionalProperties to false',
|
|
130
|
+
};
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const properties = schema.properties;
|
|
134
|
+
if (isRecord(properties)) {
|
|
135
|
+
for (const [name, child] of Object.entries(properties)) {
|
|
136
|
+
state.fields += 1;
|
|
137
|
+
if (state.fields > MAX_MANAGED_COLLECTION_RECORD_FIELDS) {
|
|
138
|
+
state.failure = {
|
|
139
|
+
code: 'invalid_managed_collection',
|
|
140
|
+
path,
|
|
141
|
+
message: `record schema declares more than ${MAX_MANAGED_COLLECTION_RECORD_FIELDS} fields`,
|
|
142
|
+
};
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (PROHIBITED_FIELD_NAME.test(name.replace(/[^a-z0-9]/giu, ''))) {
|
|
146
|
+
state.failure = {
|
|
147
|
+
code: 'invalid_managed_collection',
|
|
148
|
+
path: `${path}.properties.${name}`,
|
|
149
|
+
message: 'managed collection record schemas cannot declare prohibited sensitive fields',
|
|
150
|
+
};
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
inspectSchema(child, `${path}.properties.${name}`, depth + 1, state);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const key of SUBSCHEMA_MAP_KEYS) {
|
|
157
|
+
if (key === 'properties')
|
|
158
|
+
continue;
|
|
159
|
+
const map = schema[key];
|
|
160
|
+
if (!isRecord(map))
|
|
161
|
+
continue;
|
|
162
|
+
for (const [name, child] of Object.entries(map)) {
|
|
163
|
+
inspectSchema(child, `${path}.${key}.${name}`, depth + 1, state);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const key of SUBSCHEMA_KEYS) {
|
|
167
|
+
const child = schema[key];
|
|
168
|
+
if (child !== undefined && typeof child !== 'boolean') {
|
|
169
|
+
inspectSchema(child, `${path}.${key}`, depth + 1, state);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const key of SUBSCHEMA_LIST_KEYS) {
|
|
173
|
+
const children = schema[key];
|
|
174
|
+
if (!Array.isArray(children))
|
|
175
|
+
continue;
|
|
176
|
+
children.forEach((child, index) => {
|
|
177
|
+
inspectSchema(child, `${path}.${key}.${index}`, depth + 1, state);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function isRecord(value) {
|
|
182
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=managed-collections.js.map
|
|
@@ -1119,6 +1119,13 @@ export declare const manifestV2Schema: z.ZodObject<{
|
|
|
1119
1119
|
}, z.core.$strict>>;
|
|
1120
1120
|
}, z.core.$strict>>;
|
|
1121
1121
|
}, z.core.$strict>>>;
|
|
1122
|
+
collections: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
1123
|
+
name: z.ZodString;
|
|
1124
|
+
title: z.ZodString;
|
|
1125
|
+
description: z.ZodString;
|
|
1126
|
+
schemaVersion: z.ZodNumber;
|
|
1127
|
+
recordSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
1128
|
+
}, z.core.$strict>>>;
|
|
1122
1129
|
}, z.core.$strip>;
|
|
1123
1130
|
tools: z.ZodArray<z.ZodObject<{
|
|
1124
1131
|
name: z.ZodString;
|
|
@@ -2304,6 +2311,13 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
2304
2311
|
}, z.core.$strict>>;
|
|
2305
2312
|
}, z.core.$strict>>;
|
|
2306
2313
|
}, z.core.$strict>>>;
|
|
2314
|
+
collections: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
2315
|
+
name: z.ZodString;
|
|
2316
|
+
title: z.ZodString;
|
|
2317
|
+
description: z.ZodString;
|
|
2318
|
+
schemaVersion: z.ZodNumber;
|
|
2319
|
+
recordSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
2320
|
+
}, z.core.$strict>>>;
|
|
2307
2321
|
}, z.core.$strip>;
|
|
2308
2322
|
tools: z.ZodArray<z.ZodObject<{
|
|
2309
2323
|
name: z.ZodString;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { knowledgeComponentManifestSchema } from '@noodle-borg/knowledge/portable';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { MAX_MANAGED_COLLECTIONS, managedCollectionManifestSchema, } from '../managed-collections.js';
|
|
3
4
|
import { MAX_COMPILED_WIDGET_HTML_BYTES, MAX_RAW_WIDGET_HTML_BYTES } from '../widget-limits.js';
|
|
4
5
|
import { agentGuideSchema } from './agent-guide-schema.js';
|
|
5
6
|
import { serverAuthSchema, serverAuthV2Schema } from './auth-schema.js';
|
|
@@ -420,6 +421,8 @@ const serverV2Schema = serverSchema.extend({
|
|
|
420
421
|
agentGuide: agentGuideSchema.optional(),
|
|
421
422
|
/** Customer-owned knowledge components (ADR 0202): Core v2 only. */
|
|
422
423
|
knowledge: z.array(knowledgeComponentManifestSchema).optional(),
|
|
424
|
+
/** Managed business records: reusable schema intent only, never operator lifecycle state. */
|
|
425
|
+
collections: z.array(managedCollectionManifestSchema).max(MAX_MANAGED_COLLECTIONS).optional(),
|
|
423
426
|
});
|
|
424
427
|
const handoffSchema = z
|
|
425
428
|
.object({
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const MANAGED_RETENTION_DAYS = [7, 30, 90];
|
|
2
|
+
export const BUSINESS_ROLES = ['administrator', 'manager', 'operator', 'viewer'];
|
|
3
|
+
export const MANAGED_REQUEST_STATUSES = ['new', 'in_progress', 'resolved', 'closed'];
|
|
4
|
+
export const BUILT_IN_PROFILE_KEYS = ['travel', 'b2b_saas', 'ecommerce', 'restaurant'];
|
|
5
|
+
//# sourceMappingURL=contracts.js.map
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { activityFromRecord, applyRequestOperation, cloneActivity, cloneGrant, cloneInstallation, cloneRecord, deletedRecord, idempotencyDigest, initialRecord, installationFingerprint, normalizeInstallationInput, permissionsForBusinessRole, requestFingerprint, validateCollectionEnabled, validateExpectedRevision, } from './model.js';
|
|
3
|
+
import { afterCursor, compareRecords, decodeCursor, encodeCursor, recordKey, scopeKey, } from './pagination.js';
|
|
4
|
+
import { boundedExportPageSize, boundedPageSize, validateEmail, validateScalar, validateScope, } from './validation.js';
|
|
5
|
+
/** Process-local development/test adapter. Hosted services must use the PostgreSQL adapter. */
|
|
6
|
+
export class InMemoryBusinessInformationStore {
|
|
7
|
+
#installations = new Map();
|
|
8
|
+
#installationIds = new Map();
|
|
9
|
+
#publicIds = new Map();
|
|
10
|
+
#installationFingerprints = new Map();
|
|
11
|
+
#grants = new Map();
|
|
12
|
+
#records = new Map();
|
|
13
|
+
#activities = new Map();
|
|
14
|
+
#idempotency = new Map();
|
|
15
|
+
#locks = new Map();
|
|
16
|
+
#now;
|
|
17
|
+
#id;
|
|
18
|
+
#publicId;
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.#now = options.now ?? (() => new Date());
|
|
21
|
+
this.#id = options.id ?? randomUUID;
|
|
22
|
+
this.#publicId = options.publicId ?? (() => `sol_${randomUUID().replaceAll('-', '')}`);
|
|
23
|
+
}
|
|
24
|
+
async createInstallation(input) {
|
|
25
|
+
const normalized = normalizeInstallationInput(input);
|
|
26
|
+
const key = scopeKey(normalized.scope);
|
|
27
|
+
return this.#withLock(`installation:${normalized.scope.org}:${normalized.scope.installationId}`, async () => {
|
|
28
|
+
const existingKey = this.#installationIds.get(installationIdKey(normalized.scope.org, normalized.scope.installationId));
|
|
29
|
+
const existing = existingKey === undefined ? undefined : this.#installations.get(existingKey);
|
|
30
|
+
if (existing !== undefined) {
|
|
31
|
+
return {
|
|
32
|
+
disposition: existingKey === key &&
|
|
33
|
+
this.#installationFingerprints.get(key) === installationFingerprint(normalized)
|
|
34
|
+
? 'replayed'
|
|
35
|
+
: 'conflict',
|
|
36
|
+
installation: cloneInstallation(existing),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const now = this.#now().toISOString();
|
|
40
|
+
const installation = {
|
|
41
|
+
scope: { ...normalized.scope },
|
|
42
|
+
publicId: this.#uniquePublicId(),
|
|
43
|
+
profileKey: normalized.profileKey,
|
|
44
|
+
profileVersion: normalized.profileVersion,
|
|
45
|
+
managedCollections: [...normalized.managedCollections],
|
|
46
|
+
retentionDays: normalized.retentionDays,
|
|
47
|
+
revision: 1,
|
|
48
|
+
createdAt: now,
|
|
49
|
+
createdBySubject: normalized.actorSubject,
|
|
50
|
+
updatedAt: now,
|
|
51
|
+
updatedBySubject: normalized.actorSubject,
|
|
52
|
+
};
|
|
53
|
+
this.#installations.set(key, installation);
|
|
54
|
+
this.#installationIds.set(installationIdKey(normalized.scope.org, normalized.scope.installationId), key);
|
|
55
|
+
this.#publicIds.set(installation.publicId, key);
|
|
56
|
+
this.#installationFingerprints.set(key, installationFingerprint(normalized));
|
|
57
|
+
const administrator = {
|
|
58
|
+
scope: { ...normalized.scope },
|
|
59
|
+
subject: normalized.actorSubject,
|
|
60
|
+
...(normalized.actorEmail === undefined ? {} : { email: normalized.actorEmail }),
|
|
61
|
+
role: 'administrator',
|
|
62
|
+
revision: 1,
|
|
63
|
+
createdAt: now,
|
|
64
|
+
createdBySubject: normalized.actorSubject,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
updatedBySubject: normalized.actorSubject,
|
|
67
|
+
};
|
|
68
|
+
this.#grants.set(grantKey(normalized.scope, normalized.actorSubject), administrator);
|
|
69
|
+
return { disposition: 'created', installation: cloneInstallation(installation) };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
getInstallation(scope) {
|
|
73
|
+
const found = this.#installations.get(scopeKey(validateScope(scope)));
|
|
74
|
+
return Promise.resolve(found === undefined ? undefined : cloneInstallation(found));
|
|
75
|
+
}
|
|
76
|
+
getInstallationById(org, installationId) {
|
|
77
|
+
const key = this.#installationIds.get(installationIdKey(validateScalar('organization', org, 63), validateScalar('installation', installationId, 63)));
|
|
78
|
+
const found = key === undefined ? undefined : this.#installations.get(key);
|
|
79
|
+
return Promise.resolve(found === undefined ? undefined : cloneInstallation(found));
|
|
80
|
+
}
|
|
81
|
+
resolveInstallationByPublicId(publicId) {
|
|
82
|
+
const key = this.#publicIds.get(validateScalar('public installation id', publicId, 128));
|
|
83
|
+
const found = key === undefined ? undefined : this.#installations.get(key);
|
|
84
|
+
return Promise.resolve(found === undefined ? undefined : cloneInstallation(found));
|
|
85
|
+
}
|
|
86
|
+
listInstallations(org) {
|
|
87
|
+
const normalized = validateScalar('organization', org, 63);
|
|
88
|
+
return Promise.resolve([...this.#installations.values()]
|
|
89
|
+
.filter((installation) => installation.scope.org === normalized)
|
|
90
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
|
91
|
+
.map(cloneInstallation));
|
|
92
|
+
}
|
|
93
|
+
getGrant(scope, subject) {
|
|
94
|
+
const found = this.#grants.get(grantKey(validateScope(scope), subject));
|
|
95
|
+
return Promise.resolve(found === undefined ? undefined : cloneGrant(found));
|
|
96
|
+
}
|
|
97
|
+
listGrants(scope) {
|
|
98
|
+
const prefix = `${scopeKey(validateScope(scope))}\0`;
|
|
99
|
+
return Promise.resolve([...this.#grants.entries()]
|
|
100
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
101
|
+
.map(([, grant]) => cloneGrant(grant))
|
|
102
|
+
.sort((left, right) => left.subject.localeCompare(right.subject)));
|
|
103
|
+
}
|
|
104
|
+
async setGrant(input) {
|
|
105
|
+
validateExpectedRevision(input.expectedRevision);
|
|
106
|
+
const subject = validateScalar('grant subject', input.subject, 256);
|
|
107
|
+
const actor = validateScalar('actor subject', input.actorSubject, 256);
|
|
108
|
+
permissionsForBusinessRole(input.role);
|
|
109
|
+
const scope = validateScope(input.scope);
|
|
110
|
+
const key = grantKey(scope, subject);
|
|
111
|
+
return this.#withLock(`grants:${scopeKey(scope)}`, async () => {
|
|
112
|
+
if (!(await this.getInstallation(input.scope)))
|
|
113
|
+
return { ok: false, reason: 'not_found', currentRevision: 0 };
|
|
114
|
+
const current = this.#grants.get(key);
|
|
115
|
+
const currentRevision = current?.revision ?? 0;
|
|
116
|
+
if (currentRevision !== input.expectedRevision) {
|
|
117
|
+
return { ok: false, reason: 'conflict', currentRevision };
|
|
118
|
+
}
|
|
119
|
+
if (current?.role === 'administrator' &&
|
|
120
|
+
current.revokedAt === undefined &&
|
|
121
|
+
input.role !== 'administrator' &&
|
|
122
|
+
this.#liveAdministratorCount(scope) === 1) {
|
|
123
|
+
return { ok: false, reason: 'last_administrator', currentRevision };
|
|
124
|
+
}
|
|
125
|
+
const now = this.#now().toISOString();
|
|
126
|
+
const grant = {
|
|
127
|
+
scope: { ...input.scope },
|
|
128
|
+
subject,
|
|
129
|
+
email: validateEmail(input.email),
|
|
130
|
+
role: input.role,
|
|
131
|
+
revision: currentRevision + 1,
|
|
132
|
+
createdAt: current?.createdAt ?? now,
|
|
133
|
+
createdBySubject: current?.createdBySubject ?? actor,
|
|
134
|
+
updatedAt: now,
|
|
135
|
+
updatedBySubject: actor,
|
|
136
|
+
};
|
|
137
|
+
this.#grants.set(key, grant);
|
|
138
|
+
return { ok: true, grant: cloneGrant(grant) };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async revokeGrant(input) {
|
|
142
|
+
validateExpectedRevision(input.expectedRevision);
|
|
143
|
+
const scope = validateScope(input.scope);
|
|
144
|
+
const key = grantKey(scope, input.subject);
|
|
145
|
+
return this.#withLock(`grants:${scopeKey(scope)}`, async () => {
|
|
146
|
+
const current = this.#grants.get(key);
|
|
147
|
+
if (current === undefined)
|
|
148
|
+
return { ok: false, reason: 'not_found', currentRevision: 0 };
|
|
149
|
+
if (current.revision !== input.expectedRevision) {
|
|
150
|
+
return { ok: false, reason: 'conflict', currentRevision: current.revision };
|
|
151
|
+
}
|
|
152
|
+
if (current.role === 'administrator' &&
|
|
153
|
+
current.revokedAt === undefined &&
|
|
154
|
+
this.#liveAdministratorCount(scope) === 1) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: 'last_administrator',
|
|
158
|
+
currentRevision: current.revision,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const now = this.#now().toISOString();
|
|
162
|
+
const grant = {
|
|
163
|
+
...current,
|
|
164
|
+
revision: current.revision + 1,
|
|
165
|
+
updatedAt: now,
|
|
166
|
+
updatedBySubject: validateScalar('actor subject', input.actorSubject, 256),
|
|
167
|
+
revokedAt: now,
|
|
168
|
+
};
|
|
169
|
+
this.#grants.set(key, grant);
|
|
170
|
+
return { ok: true, grant: cloneGrant(grant) };
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async createRequest(input) {
|
|
174
|
+
const idempotencyKey = idempotencyDigest(input.idempotencyKey);
|
|
175
|
+
const installation = await this.#requiredInstallation(input.scope);
|
|
176
|
+
validateCollectionEnabled(installation, input.collectionKey);
|
|
177
|
+
const key = `${scopeKey(input.scope)}\0${input.collectionKey}\0${idempotencyKey}`;
|
|
178
|
+
return this.#withLock(`request-create:${key}`, async () => {
|
|
179
|
+
const candidate = initialRecord({
|
|
180
|
+
installation,
|
|
181
|
+
collectionKey: input.collectionKey,
|
|
182
|
+
id: this.#id(),
|
|
183
|
+
payload: input.payload,
|
|
184
|
+
origin: input.origin,
|
|
185
|
+
actorSubject: input.actorSubject,
|
|
186
|
+
now: this.#now(),
|
|
187
|
+
});
|
|
188
|
+
const fingerprint = requestFingerprint({
|
|
189
|
+
collectionKey: candidate.collectionKey,
|
|
190
|
+
payload: candidate.content?.payload,
|
|
191
|
+
origin: candidate.origin,
|
|
192
|
+
actorSubject: candidate.createdBySubject,
|
|
193
|
+
});
|
|
194
|
+
const replay = this.#idempotency.get(key);
|
|
195
|
+
if (replay !== undefined) {
|
|
196
|
+
const existing = this.#records.get(replay.recordKey);
|
|
197
|
+
if (existing === undefined)
|
|
198
|
+
throw new Error('idempotency record points to a missing request');
|
|
199
|
+
return {
|
|
200
|
+
disposition: replay.fingerprint === fingerprint ? 'replayed' : 'conflict',
|
|
201
|
+
record: cloneRecord(existing),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const currentKey = recordKey(input.scope, candidate.collectionKey, candidate.id);
|
|
205
|
+
this.#records.set(currentKey, candidate);
|
|
206
|
+
this.#activities.set(currentKey, [activityFromRecord(candidate, 'created')]);
|
|
207
|
+
this.#idempotency.set(key, { fingerprint, recordKey: currentKey });
|
|
208
|
+
return { disposition: 'created', record: cloneRecord(candidate) };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
#liveAdministratorCount(scope) {
|
|
212
|
+
const prefix = `${scopeKey(scope)}\0`;
|
|
213
|
+
return [...this.#grants.entries()].filter(([key, grant]) => key.startsWith(prefix) && grant.role === 'administrator' && grant.revokedAt === undefined).length;
|
|
214
|
+
}
|
|
215
|
+
async getRequest(scope, collectionKey, id, options = {}) {
|
|
216
|
+
const installation = await this.#requiredInstallation(scope);
|
|
217
|
+
const collection = validateCollectionEnabled(installation, collectionKey);
|
|
218
|
+
const record = this.#records.get(recordKey(scope, collection.key, validateScalar('record id', id, 128)));
|
|
219
|
+
if (record === undefined || (record.deletedAt !== undefined && options.includeDeleted !== true))
|
|
220
|
+
return undefined;
|
|
221
|
+
return cloneRecord(record);
|
|
222
|
+
}
|
|
223
|
+
async listRequests(input) {
|
|
224
|
+
const installation = await this.#requiredInstallation(input.scope);
|
|
225
|
+
const collection = validateCollectionEnabled(installation, input.collectionKey);
|
|
226
|
+
const limit = boundedPageSize(input.limit);
|
|
227
|
+
const cursor = input.cursor === undefined
|
|
228
|
+
? undefined
|
|
229
|
+
: decodeCursor(input.cursor, {
|
|
230
|
+
kind: 'list',
|
|
231
|
+
scope: input.scope,
|
|
232
|
+
collectionKey: collection.key,
|
|
233
|
+
});
|
|
234
|
+
const records = this.#matchingRecords(input.scope, collection.key)
|
|
235
|
+
.filter((record) => input.includeDeleted === true || record.deletedAt === undefined)
|
|
236
|
+
.filter((record) => input.status === undefined || record.status === input.status)
|
|
237
|
+
.filter((record) => input.assigneeSubject === undefined || record.assigneeSubject === input.assigneeSubject)
|
|
238
|
+
.filter((record) => cursor === undefined || afterCursor(record, cursor));
|
|
239
|
+
return page(records, limit, 'list', input.scope, collection.key);
|
|
240
|
+
}
|
|
241
|
+
async mutateRequest(input) {
|
|
242
|
+
validateExpectedRevision(input.expectedRevision);
|
|
243
|
+
const installation = await this.#requiredInstallation(input.scope);
|
|
244
|
+
const collection = validateCollectionEnabled(installation, input.collectionKey);
|
|
245
|
+
const key = recordKey(input.scope, collection.key, validateScalar('record id', input.id, 128));
|
|
246
|
+
return this.#withLock(`request:${key}`, async () => {
|
|
247
|
+
const current = this.#records.get(key);
|
|
248
|
+
if (current === undefined || current.deletedAt !== undefined) {
|
|
249
|
+
return { ok: false, reason: 'not_found', currentRevision: 0 };
|
|
250
|
+
}
|
|
251
|
+
if (current.revision !== input.expectedRevision) {
|
|
252
|
+
return { ok: false, reason: 'conflict', currentRevision: current.revision };
|
|
253
|
+
}
|
|
254
|
+
const applied = applyRequestOperation(current, input.operation, input.actorSubject, this.#now(), this.#id);
|
|
255
|
+
if (applied === undefined) {
|
|
256
|
+
return { ok: false, reason: 'invalid_transition', currentRevision: current.revision };
|
|
257
|
+
}
|
|
258
|
+
this.#records.set(key, applied.record);
|
|
259
|
+
this.#activities.get(key)?.push(activityFromRecord(applied.record, applied.activityKind));
|
|
260
|
+
return { ok: true, record: cloneRecord(applied.record) };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
async deleteRequest(input) {
|
|
264
|
+
validateExpectedRevision(input.expectedRevision);
|
|
265
|
+
const installation = await this.#requiredInstallation(input.scope);
|
|
266
|
+
const collection = validateCollectionEnabled(installation, input.collectionKey);
|
|
267
|
+
const key = recordKey(input.scope, collection.key, validateScalar('record id', input.id, 128));
|
|
268
|
+
return this.#withLock(`request:${key}`, () => Promise.resolve(this.#eraseRecord(key, input.expectedRevision, input.actorSubject, 'customer_request')));
|
|
269
|
+
}
|
|
270
|
+
listActivity(scope, collectionKey, id) {
|
|
271
|
+
const key = recordKey(validateScope(scope), collectionKey, validateScalar('record id', id, 128));
|
|
272
|
+
return Promise.resolve((this.#activities.get(key) ?? []).map(cloneActivity));
|
|
273
|
+
}
|
|
274
|
+
async exportRequests(input) {
|
|
275
|
+
const installation = await this.#requiredInstallation(input.scope);
|
|
276
|
+
const collection = validateCollectionEnabled(installation, input.collectionKey);
|
|
277
|
+
const limit = boundedExportPageSize(input.limit);
|
|
278
|
+
const cursor = input.cursor === undefined
|
|
279
|
+
? undefined
|
|
280
|
+
: decodeCursor(input.cursor, {
|
|
281
|
+
kind: 'export',
|
|
282
|
+
scope: input.scope,
|
|
283
|
+
collectionKey: collection.key,
|
|
284
|
+
});
|
|
285
|
+
const snapshotAt = cursor?.snapshotAt ?? this.#now().toISOString();
|
|
286
|
+
if (snapshotAt === undefined)
|
|
287
|
+
throw new Error('export cursor is missing its snapshot');
|
|
288
|
+
const records = this.#matchingRecords(input.scope, collection.key)
|
|
289
|
+
.filter((record) => record.createdAt <= snapshotAt)
|
|
290
|
+
.filter((record) => input.includeDeleted === true || record.deletedAt === undefined)
|
|
291
|
+
.filter((record) => cursor === undefined || afterCursor(record, cursor));
|
|
292
|
+
return {
|
|
293
|
+
...page(records, limit, 'export', input.scope, collection.key, snapshotAt),
|
|
294
|
+
snapshotAt,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
async purgeExpired(input) {
|
|
298
|
+
const limit = boundedPageSize(input.limit);
|
|
299
|
+
const scope = input.scope === undefined ? undefined : scopeKey(validateScope(input.scope));
|
|
300
|
+
const now = this.#now();
|
|
301
|
+
const candidates = [...this.#records.entries()]
|
|
302
|
+
.filter(([key, record]) => (scope === undefined || key.startsWith(`${scope}\0`)) &&
|
|
303
|
+
record.deletedAt === undefined &&
|
|
304
|
+
record.retentionExpiresAt <= now.toISOString())
|
|
305
|
+
.slice(0, limit);
|
|
306
|
+
let purged = 0;
|
|
307
|
+
for (const [key, record] of candidates) {
|
|
308
|
+
const result = await this.#withLock(`request:${key}`, () => Promise.resolve(this.#eraseRecord(key, record.revision, 'system:retention', 'retention_expired')));
|
|
309
|
+
if (result.ok)
|
|
310
|
+
purged += 1;
|
|
311
|
+
}
|
|
312
|
+
return purged;
|
|
313
|
+
}
|
|
314
|
+
#matchingRecords(scope, collectionKey) {
|
|
315
|
+
const prefix = `${scopeKey(scope)}\0${collectionKey}\0`;
|
|
316
|
+
return [...this.#records.entries()]
|
|
317
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
318
|
+
.map(([, record]) => record)
|
|
319
|
+
.sort(compareRecords);
|
|
320
|
+
}
|
|
321
|
+
#eraseRecord(key, expectedRevision, actorSubject, reason) {
|
|
322
|
+
const current = this.#records.get(key);
|
|
323
|
+
if (current === undefined || current.deletedAt !== undefined) {
|
|
324
|
+
return { ok: false, reason: 'not_found', currentRevision: current?.revision ?? 0 };
|
|
325
|
+
}
|
|
326
|
+
if (current.revision !== expectedRevision) {
|
|
327
|
+
return { ok: false, reason: 'conflict', currentRevision: current.revision };
|
|
328
|
+
}
|
|
329
|
+
const erased = deletedRecord(current, actorSubject, this.#now(), reason);
|
|
330
|
+
this.#records.set(key, erased);
|
|
331
|
+
const erasedHistory = (this.#activities.get(key) ?? []).map((activity) => {
|
|
332
|
+
const { content: ignored, ...metadata } = activity;
|
|
333
|
+
void ignored;
|
|
334
|
+
return metadata;
|
|
335
|
+
});
|
|
336
|
+
this.#activities.set(key, [
|
|
337
|
+
...erasedHistory,
|
|
338
|
+
activityFromRecord(erased, reason === 'customer_request' ? 'deleted' : 'retention_expired'),
|
|
339
|
+
]);
|
|
340
|
+
return { ok: true, record: cloneRecord(erased) };
|
|
341
|
+
}
|
|
342
|
+
async #requiredInstallation(scope) {
|
|
343
|
+
const installation = await this.getInstallation(scope);
|
|
344
|
+
if (installation === undefined)
|
|
345
|
+
throw new Error('solution installation was not found');
|
|
346
|
+
return installation;
|
|
347
|
+
}
|
|
348
|
+
#uniquePublicId() {
|
|
349
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
350
|
+
const candidate = validateScalar('public installation id', this.#publicId(), 128);
|
|
351
|
+
if (!this.#publicIds.has(candidate))
|
|
352
|
+
return candidate;
|
|
353
|
+
}
|
|
354
|
+
throw new Error('could not allocate a unique public installation id');
|
|
355
|
+
}
|
|
356
|
+
async #withLock(key, operation) {
|
|
357
|
+
const previous = this.#locks.get(key) ?? Promise.resolve();
|
|
358
|
+
let release = () => void 0;
|
|
359
|
+
const current = new Promise((resolve) => {
|
|
360
|
+
release = resolve;
|
|
361
|
+
});
|
|
362
|
+
const queued = previous.then(() => current);
|
|
363
|
+
this.#locks.set(key, queued);
|
|
364
|
+
await previous;
|
|
365
|
+
try {
|
|
366
|
+
return await operation();
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
release(undefined);
|
|
370
|
+
if (this.#locks.get(key) === queued)
|
|
371
|
+
this.#locks.delete(key);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function installationIdKey(org, installationId) {
|
|
376
|
+
return `${org}\0${installationId}`;
|
|
377
|
+
}
|
|
378
|
+
function grantKey(scope, subject) {
|
|
379
|
+
return `${scopeKey(scope)}\0${validateScalar('grant subject', subject, 256)}`;
|
|
380
|
+
}
|
|
381
|
+
function page(records, limit, kind, scope, collectionKey, snapshotAt) {
|
|
382
|
+
const selected = records.slice(0, limit).map(cloneRecord);
|
|
383
|
+
const last = selected.at(-1);
|
|
384
|
+
const hasMore = records.length > limit;
|
|
385
|
+
return {
|
|
386
|
+
records: selected,
|
|
387
|
+
...(hasMore && last !== undefined
|
|
388
|
+
? {
|
|
389
|
+
nextCursor: encodeCursor({
|
|
390
|
+
version: 1,
|
|
391
|
+
kind,
|
|
392
|
+
scope: scopeKey(scope),
|
|
393
|
+
collectionKey,
|
|
394
|
+
lastCreatedAt: last.createdAt,
|
|
395
|
+
lastId: last.id,
|
|
396
|
+
...(snapshotAt === undefined ? {} : { snapshotAt }),
|
|
397
|
+
}),
|
|
398
|
+
}
|
|
399
|
+
: {}),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
//# sourceMappingURL=in-memory-store.js.map
|