@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.
Files changed (22) hide show
  1. package/dist/commands/solutions-conversations.d.ts +6 -0
  2. package/dist/commands/solutions-conversations.d.ts.map +1 -0
  3. package/dist/commands/solutions-conversations.js +137 -0
  4. package/dist/commands/solutions-conversations.js.map +1 -0
  5. package/dist/commands/solutions-ops.d.ts.map +1 -1
  6. package/dist/commands/solutions-ops.js +3 -0
  7. package/dist/commands/solutions-ops.js.map +1 -1
  8. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +97 -0
  9. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solutions.js +2 -0
  10. package/node_modules/@noodle-borg/service/dist/channels/provider.js +40 -4
  11. package/node_modules/@noodle-borg/service/dist/conversation-history/operator.js +184 -0
  12. package/node_modules/@noodle-borg/service/dist/routes/business-information-conversations.js +65 -0
  13. package/node_modules/@noodle-borg/service/dist/routes/business-information-dispatch.js +3 -0
  14. package/node_modules/@noodle-borg/service/dist/routes/business-information-paths.js +16 -0
  15. package/node_modules/@noodle-borg/service/dist/service-history-projections.js +24 -0
  16. package/node_modules/@noodle-borg/service/dist/service.js +3 -7
  17. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +347 -0
  18. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +120 -0
  19. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  20. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  21. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +2 -2
  22. 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.operationEvidence === undefined
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
  : {