@noodleseed/one 0.176.0 → 0.177.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/commands/solutions-conversations.d.ts +6 -0
- package/dist/commands/solutions-conversations.d.ts.map +1 -0
- package/dist/commands/solutions-conversations.js +137 -0
- package/dist/commands/solutions-conversations.js.map +1 -0
- package/dist/commands/solutions-ops.d.ts.map +1 -1
- package/dist/commands/solutions-ops.js +3 -0
- package/dist/commands/solutions-ops.js.map +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +97 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solutions.js +2 -0
- package/node_modules/@noodle-borg/service/dist/channels/provider.js +40 -4
- package/node_modules/@noodle-borg/service/dist/conversation-history/operator.js +184 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information-conversations.js +65 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information-dispatch.js +3 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information-paths.js +16 -0
- package/node_modules/@noodle-borg/service/dist/service-history-projections.js +24 -0
- package/node_modules/@noodle-borg/service/dist/service.js +3 -7
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +347 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +120 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from '@noodle-borg/compiler';
|
|
3
|
+
import { ConversationExportResponseSchema, ConversationForgetRequestSchema, ConversationForgetResponseSchema, ConversationIdSchema, ConversationListResponseSchema, ConversationShowResponseSchema, } from '@noodle-borg/wire-contracts';
|
|
4
|
+
const runLocal = (operation) => operation();
|
|
5
|
+
const MESSAGES = {
|
|
6
|
+
conversation_invalid: 'Invalid conversation request.',
|
|
7
|
+
conversation_not_found: 'Conversation not found.',
|
|
8
|
+
conversation_unavailable: 'Conversation history is unavailable.',
|
|
9
|
+
};
|
|
10
|
+
export class ConversationHistoryError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code) {
|
|
13
|
+
super(MESSAGES[code]);
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const PAGE = { list: { default: 50, maximum: 100 }, export: { default: 25, maximum: 25 } };
|
|
18
|
+
/**
|
|
19
|
+
* The staff projection of conversation history (ADR 0241). Callers authorize before and after, and every
|
|
20
|
+
* read, export and forget returns identifier-only audit details; message text never reaches audit.
|
|
21
|
+
*/
|
|
22
|
+
export class ApplicationConversations {
|
|
23
|
+
options;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
}
|
|
27
|
+
async project(scope, action, input, local = runLocal) {
|
|
28
|
+
const tenant = { org: scope.org, app: scope.app, env: scope.env };
|
|
29
|
+
const allowed = action === 'list' || action === 'export' ? ['limit', 'cursor', 'channel'] : [];
|
|
30
|
+
const parameters = new Map();
|
|
31
|
+
for (const [key, value] of input.parameters) {
|
|
32
|
+
if (!allowed.includes(key) || parameters.has(key))
|
|
33
|
+
throw invalid();
|
|
34
|
+
parameters.set(key, value);
|
|
35
|
+
}
|
|
36
|
+
const audit = (eventType, details) => ({
|
|
37
|
+
eventType,
|
|
38
|
+
details,
|
|
39
|
+
});
|
|
40
|
+
if (action === 'forget') {
|
|
41
|
+
const parsed = ConversationForgetRequestSchema.safeParse(input.body);
|
|
42
|
+
if (!parsed.success)
|
|
43
|
+
throw invalid();
|
|
44
|
+
const request = parsed.data;
|
|
45
|
+
const forgotten = await local(() => 'conversationId' in request
|
|
46
|
+
? this.options.store.forget(tenant, request.conversationId)
|
|
47
|
+
: this.options.store.forgetSubject(tenant, request.subject));
|
|
48
|
+
return {
|
|
49
|
+
response: ConversationForgetResponseSchema.parse({ ok: true, data: { forgotten } }),
|
|
50
|
+
audit: audit('conversation.forgotten', {
|
|
51
|
+
conversations: forgotten.conversations,
|
|
52
|
+
items: forgotten.items,
|
|
53
|
+
kind: 'conversationId' in request ? 'conversation' : request.subject.kind,
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (action === 'show') {
|
|
58
|
+
const id = ConversationIdSchema.safeParse(input.conversationId);
|
|
59
|
+
if (!id.success)
|
|
60
|
+
throw new ConversationHistoryError('conversation_not_found');
|
|
61
|
+
const conversation = await local(() => this.options.store.read(tenant, id.data, this.#now()));
|
|
62
|
+
if (!conversation)
|
|
63
|
+
throw new ConversationHistoryError('conversation_not_found');
|
|
64
|
+
return {
|
|
65
|
+
response: ConversationShowResponseSchema.parse({
|
|
66
|
+
ok: true,
|
|
67
|
+
data: { conversation: detail(conversation) },
|
|
68
|
+
}),
|
|
69
|
+
audit: audit('conversation.read', { conversationId: id.data }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const page = await this.#page(scope, tenant, action, parameters, local);
|
|
73
|
+
if (action === 'list')
|
|
74
|
+
return {
|
|
75
|
+
response: ConversationListResponseSchema.parse({
|
|
76
|
+
ok: true,
|
|
77
|
+
data: {
|
|
78
|
+
conversations: page.summaries.map(summary),
|
|
79
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
const conversations = await local(async () => {
|
|
84
|
+
const now = this.#now();
|
|
85
|
+
const read = await Promise.all(page.summaries.map((row) => this.options.store.read(tenant, row.id, now)));
|
|
86
|
+
return read.filter((row) => row !== undefined).map(detail);
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
response: ConversationExportResponseSchema.parse({
|
|
90
|
+
ok: true,
|
|
91
|
+
data: {
|
|
92
|
+
conversations,
|
|
93
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
audit: audit('conversation.exported', { count: conversations.length }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async #page(scope, tenant, purpose, parameters, local) {
|
|
100
|
+
const bounds = PAGE[purpose];
|
|
101
|
+
const limit = parameters.has('limit') ? Number(parameters.get('limit')) : bounds.default;
|
|
102
|
+
const channel = parameters.get('channel');
|
|
103
|
+
const cursor = parameters.get('cursor');
|
|
104
|
+
if (!Number.isInteger(limit) ||
|
|
105
|
+
limit < 1 ||
|
|
106
|
+
limit > bounds.maximum ||
|
|
107
|
+
(channel !== undefined && channel !== 'website' && channel !== 'whatsapp') ||
|
|
108
|
+
(cursor !== undefined && (!cursor || cursor.length > 2048)))
|
|
109
|
+
throw invalid();
|
|
110
|
+
const binding = this.#hash({
|
|
111
|
+
purpose: `conversations-${purpose}-v1`,
|
|
112
|
+
scope,
|
|
113
|
+
limit,
|
|
114
|
+
channel: channel ?? null,
|
|
115
|
+
});
|
|
116
|
+
const after = decodeConversationCursor(cursor, binding);
|
|
117
|
+
const rows = await local(() => this.options.store.list(tenant, {
|
|
118
|
+
now: this.#now(),
|
|
119
|
+
limit: limit + 1,
|
|
120
|
+
...(after ? { after } : {}),
|
|
121
|
+
...(channel ? { channel: channel } : {}),
|
|
122
|
+
}));
|
|
123
|
+
const summaries = rows.slice(0, limit);
|
|
124
|
+
const last = summaries.at(-1);
|
|
125
|
+
return {
|
|
126
|
+
summaries,
|
|
127
|
+
nextCursor: rows.length > limit && last
|
|
128
|
+
? Buffer.from(JSON.stringify({ binding, lastMessageAt: last.lastMessageAt, id: last.id })).toString('base64url')
|
|
129
|
+
: undefined,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
#now() {
|
|
133
|
+
return this.options.now?.() ?? Date.now();
|
|
134
|
+
}
|
|
135
|
+
#hash(value) {
|
|
136
|
+
return createHmac('sha256', this.options.identityKey)
|
|
137
|
+
.update(canonicalJson(value))
|
|
138
|
+
.digest('hex');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function invalid() {
|
|
142
|
+
return new ConversationHistoryError('conversation_invalid');
|
|
143
|
+
}
|
|
144
|
+
function summary(row) {
|
|
145
|
+
return {
|
|
146
|
+
id: row.id,
|
|
147
|
+
channel: row.channel,
|
|
148
|
+
subject: row.subject.kind === 'anonymous' ? { kind: 'anonymous' } : row.subject,
|
|
149
|
+
startedAt: new Date(row.startedAt).toISOString(),
|
|
150
|
+
lastMessageAt: new Date(row.lastMessageAt).toISOString(),
|
|
151
|
+
itemCount: row.itemCount,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function detail(conversation) {
|
|
155
|
+
return {
|
|
156
|
+
...summary({ ...conversation, itemCount: conversation.items.length }),
|
|
157
|
+
items: conversation.items.map((item) => item.kind === 'message'
|
|
158
|
+
? { kind: item.kind, role: item.role, text: item.text, at: new Date(item.at).toISOString() }
|
|
159
|
+
: {
|
|
160
|
+
kind: item.kind,
|
|
161
|
+
interactionId: item.interactionId,
|
|
162
|
+
tool: item.tool,
|
|
163
|
+
status: item.status,
|
|
164
|
+
at: new Date(item.at).toISOString(),
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function decodeConversationCursor(raw, binding) {
|
|
169
|
+
if (raw === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
try {
|
|
172
|
+
const value = JSON.parse(Buffer.from(raw, 'base64url').toString());
|
|
173
|
+
if (value.binding !== binding ||
|
|
174
|
+
!Number.isSafeInteger(value.lastMessageAt) ||
|
|
175
|
+
value.lastMessageAt < 0 ||
|
|
176
|
+
!ConversationIdSchema.safeParse(value.id).success)
|
|
177
|
+
throw new Error();
|
|
178
|
+
return { lastMessageAt: value.lastMessageAt, id: value.id };
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw invalid();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=operator.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
|
|
2
|
+
import { ConversationHistoryError, } from '../conversation-history/operator.js';
|
|
3
|
+
import { requireIdentity, requireInstallationPermission, } from './business-information.js';
|
|
4
|
+
/** ADR 0241 decision 10: readers see text; only export and erasure need Owner/Administrator authority. */
|
|
5
|
+
const PERMISSIONS = {
|
|
6
|
+
list: 'records:read',
|
|
7
|
+
show: 'records:read',
|
|
8
|
+
export: 'records:export',
|
|
9
|
+
forget: 'records:delete',
|
|
10
|
+
};
|
|
11
|
+
export async function handleApplicationConversations(req, res, url, ref, deps) {
|
|
12
|
+
const action = ref.conversationAction ?? 'list';
|
|
13
|
+
const method = action === 'forget' ? 'POST' : 'GET';
|
|
14
|
+
if (req.method !== method) {
|
|
15
|
+
res.setHeader('allow', method);
|
|
16
|
+
return sendJson(res, 405, { error: 'method not allowed' });
|
|
17
|
+
}
|
|
18
|
+
const identity = await requireIdentity(req, res, deps);
|
|
19
|
+
if (identity === false)
|
|
20
|
+
return;
|
|
21
|
+
const permission = PERMISSIONS[action];
|
|
22
|
+
const authorize = () => requireInstallationPermission(res, ref, identity, permission, deps);
|
|
23
|
+
const authorized = await authorize();
|
|
24
|
+
if (!authorized)
|
|
25
|
+
return;
|
|
26
|
+
res.setHeader('cache-control', 'private, no-store');
|
|
27
|
+
try {
|
|
28
|
+
if (!deps.conversations)
|
|
29
|
+
throw new ConversationHistoryError('conversation_unavailable');
|
|
30
|
+
let body;
|
|
31
|
+
if (method === 'POST') {
|
|
32
|
+
const parsed = await readJsonBody(req, deps.maxBody);
|
|
33
|
+
if (!parsed.ok)
|
|
34
|
+
return sendJson(res, parsed.status, { error: parsed.error });
|
|
35
|
+
body = parsed.value;
|
|
36
|
+
if (!(await authorize()))
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const result = await deps.conversations.project(authorized.scope, action, {
|
|
40
|
+
parameters: [...url.searchParams.entries()],
|
|
41
|
+
...(ref.conversationId === undefined ? {} : { conversationId: ref.conversationId }),
|
|
42
|
+
body,
|
|
43
|
+
}, (operation) => deps.store.staff.run(authorized.scope, identity.subject, permission, operation));
|
|
44
|
+
if ('audit' in result && result.audit)
|
|
45
|
+
await deps.audit?.emit({
|
|
46
|
+
...result.audit,
|
|
47
|
+
org: ref.org,
|
|
48
|
+
app: authorized.scope.app,
|
|
49
|
+
env: authorized.scope.env,
|
|
50
|
+
actorSubject: identity.subject,
|
|
51
|
+
});
|
|
52
|
+
if (await authorize())
|
|
53
|
+
sendJson(res, 200, result.response);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!(error instanceof ConversationHistoryError))
|
|
57
|
+
throw error;
|
|
58
|
+
sendJson(res, error.code === 'conversation_not_found'
|
|
59
|
+
? 404
|
|
60
|
+
: error.code === 'conversation_unavailable'
|
|
61
|
+
? 503
|
|
62
|
+
: 400, { code: error.code, error: error.message });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=business-information-conversations.js.map
|
|
@@ -12,6 +12,7 @@ import { handleBusinessGrants, handleManagedRecords, handleSolutionCatalog, hand
|
|
|
12
12
|
import { handleApplicationActivity, } from './business-information-activity.js';
|
|
13
13
|
import { handleBusinessChannels, } from './business-information-channels.js';
|
|
14
14
|
import { handleApplicationConnectionCallback, handleApplicationConnections, } from './business-information-connections.js';
|
|
15
|
+
import { handleApplicationConversations, } from './business-information-conversations.js';
|
|
15
16
|
import { handleBusinessNotice, handleOrganizationAgreement, } from './business-information-notice.js';
|
|
16
17
|
import { handleSolutionInstallationOptions } from './business-information-onboarding.js';
|
|
17
18
|
import { handleBusinessPage, handlePublicBusinessPage } from './business-information-pages.js';
|
|
@@ -81,6 +82,8 @@ export function dispatchBusinessInformationRoutes(req, res, url, deps) {
|
|
|
81
82
|
if ((installationRef.action === 'activity' || installationRef.action === 'coordination') &&
|
|
82
83
|
installationRef.collection === undefined)
|
|
83
84
|
return run(req, res, deps, () => handleApplicationActivity(req, res, url, installationRef, deps));
|
|
85
|
+
if (installationRef.action === 'conversations')
|
|
86
|
+
return run(req, res, deps, () => handleApplicationConversations(req, res, url, installationRef, deps));
|
|
84
87
|
if (installationRef.action === 'connections')
|
|
85
88
|
return run(req, res, deps, () => handleApplicationConnections(req, res, installationRef, deps));
|
|
86
89
|
if (installationRef.action === 'settings') {
|
|
@@ -27,6 +27,22 @@ export function parseSolutionInstallationPath(pathname) {
|
|
|
27
27
|
...(connectionAction === undefined ? {} : { connectionAction }),
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
|
+
const conversations = /^\/v1\/orgs\/([^/]+)\/solution-installations\/([^/]+)\/conversations(?:\/(export|forget|cv_[A-Za-z0-9_-]{8,64}))?$/.exec(pathname);
|
|
31
|
+
if (conversations !== null) {
|
|
32
|
+
const org = decoded(conversations, 1), installationId = decoded(conversations, 2), segment = conversations[3];
|
|
33
|
+
return org === undefined || installationId === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: {
|
|
36
|
+
org,
|
|
37
|
+
installationId,
|
|
38
|
+
action: 'conversations',
|
|
39
|
+
...(segment === undefined
|
|
40
|
+
? { conversationAction: 'list' }
|
|
41
|
+
: segment === 'export' || segment === 'forget'
|
|
42
|
+
? { conversationAction: segment }
|
|
43
|
+
: { conversationAction: 'show', conversationId: segment }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
30
46
|
const application = /^\/v1\/orgs\/([^/]+)\/solution-installations\/([^/]+)\/(activate|settings|notice|record-lifecycle|channels|operations\/coordination(?:\/resolve)?|activity(?:\/(?:settings|export|preview))?)$/.exec(pathname);
|
|
31
47
|
if (application !== null) {
|
|
32
48
|
const org = decoded(application, 1);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { ApplicationActivity } from './application-activity.js';
|
|
3
|
+
import { ApplicationConversations } from './conversation-history/operator.js';
|
|
4
|
+
/** Installation history projections over the optional durable stores the service was given. */
|
|
5
|
+
export function createHistoryProjections(options, moduleHost) {
|
|
6
|
+
const activity = options.operationEvidence === undefined
|
|
7
|
+
? undefined
|
|
8
|
+
: new ApplicationActivity({
|
|
9
|
+
...options.operationEvidence,
|
|
10
|
+
allowance: async (org, request) => moduleHost.resolveActivityHistoryAllowance?.(org, request),
|
|
11
|
+
});
|
|
12
|
+
const history = options.conversationHistory;
|
|
13
|
+
const conversations = history === undefined
|
|
14
|
+
? undefined
|
|
15
|
+
: new ApplicationConversations({
|
|
16
|
+
store: history.store,
|
|
17
|
+
identityKey: history.identityKey ?? randomBytes(32).toString('hex'),
|
|
18
|
+
...(options.clock === undefined
|
|
19
|
+
? {}
|
|
20
|
+
: { now: () => options.clock?.().getTime() ?? Date.now() }),
|
|
21
|
+
});
|
|
22
|
+
return { activity, conversations };
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=service-history-projections.js.map
|
|
@@ -6,7 +6,6 @@ import { capabilityOperatorRoute } from '@noodle-borg/managed-capabilities';
|
|
|
6
6
|
import { OPENAI_APPS_CHALLENGE_PATH } from '@noodle-borg/module';
|
|
7
7
|
import { applySecurityHeaders, createMcpRouter, enforceHttps, noopLogger, sendJson, } from '@noodle-borg/transport-http';
|
|
8
8
|
import { AnonymousConsumerLimiter, createAdmissionGate } from './admission.js';
|
|
9
|
-
import { ApplicationActivity } from './application-activity.js';
|
|
10
9
|
import { createApplicationServingRuntime } from './application-runtime-target.js';
|
|
11
10
|
import { createArchivePreflight } from './archive-preflight.js';
|
|
12
11
|
import { ArchiveSweeper, resolveArchiveRetentionDays } from './archive-sweeper.js';
|
|
@@ -55,6 +54,7 @@ import { handleRollback } from './routes/rollback.js';
|
|
|
55
54
|
import { createServicePrincipalDispatcher } from './routes/service-principals-dispatch.js';
|
|
56
55
|
import { createServiceProbeDispatcher } from './routes/service-probes.js';
|
|
57
56
|
import { dispatchWhatsAppRoutes } from './routes/whatsapp.js';
|
|
57
|
+
import { createHistoryProjections } from './service-history-projections.js';
|
|
58
58
|
import { servicePrincipalDataPlaneHooks } from './service-principal-data-plane.js';
|
|
59
59
|
import { InMemoryAlertRuleStore } from './store/alert-rules.js';
|
|
60
60
|
import { StdoutAuditSink } from './store/audit.js';
|
|
@@ -95,12 +95,7 @@ export function createServiceHandler(registry, options = {}) {
|
|
|
95
95
|
activeAudit = audit;
|
|
96
96
|
const { publicTenantRouting } = mcp.mcpRoutingOptions(options, controlPlane);
|
|
97
97
|
const resolveEndpointOptions = (org) => mcp.endpointUrlOptionsForOrg(options, controlPlane, org);
|
|
98
|
-
const activity = options
|
|
99
|
-
? undefined
|
|
100
|
-
: new ApplicationActivity({
|
|
101
|
-
...options.operationEvidence,
|
|
102
|
-
allowance: async (org, request) => moduleHost.resolveActivityHistoryAllowance?.(org, request),
|
|
103
|
-
});
|
|
98
|
+
const { activity, conversations } = createHistoryProjections(options, moduleHost);
|
|
104
99
|
const withIntentMode = createIntentTargetResolver(intentSettings, intentPreviewOrgs);
|
|
105
100
|
const { activateInstallation, readInstallationActivation, resolveRuntimeTarget, businessOnboarding, } = createApplicationServingRuntime(registry, providerOptions, controlPlane, audit, activity);
|
|
106
101
|
const runtimeTarget = async (target) => target === undefined ? undefined : resolveRuntimeTarget(target);
|
|
@@ -314,6 +309,7 @@ export function createServiceHandler(registry, options = {}) {
|
|
|
314
309
|
activateInstallation,
|
|
315
310
|
readInstallationActivation,
|
|
316
311
|
...(activity === undefined ? {} : { activity }),
|
|
312
|
+
...(conversations === undefined ? {} : { conversations }),
|
|
317
313
|
...(options.connectionRuntime === undefined
|
|
318
314
|
? {}
|
|
319
315
|
: {
|