crewly 1.20.209 → 1.20.211

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 (93) hide show
  1. package/config/skills/agent/core/app-data/SKILL.md +117 -0
  2. package/config/skills/agent/core/app-data/execute.sh +141 -0
  3. package/config/skills/agent/core/app-data/execute.test.sh +108 -0
  4. package/config/skills/agent/core/publish-app/SKILL.md +248 -0
  5. package/config/skills/agent/core/publish-app/execute.sh +302 -0
  6. package/config/skills/agent/core/publish-app/execute.test.sh +205 -0
  7. package/dist/backend/backend/src/constants.d.ts +115 -0
  8. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  9. package/dist/backend/backend/src/constants.js +115 -0
  10. package/dist/backend/backend/src/constants.js.map +1 -1
  11. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts +55 -0
  12. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts.map +1 -0
  13. package/dist/backend/backend/src/controllers/apps/apps.controller.js +131 -0
  14. package/dist/backend/backend/src/controllers/apps/apps.controller.js.map +1 -0
  15. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts +53 -0
  16. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts.map +1 -0
  17. package/dist/backend/backend/src/controllers/apps/apps.routes.js +108 -0
  18. package/dist/backend/backend/src/controllers/apps/apps.routes.js.map +1 -0
  19. package/dist/backend/backend/src/index.d.ts +1 -0
  20. package/dist/backend/backend/src/index.d.ts.map +1 -1
  21. package/dist/backend/backend/src/index.js +57 -5
  22. package/dist/backend/backend/src/index.js.map +1 -1
  23. package/dist/backend/backend/src/middleware/body-parser-except.d.ts +25 -0
  24. package/dist/backend/backend/src/middleware/body-parser-except.d.ts.map +1 -0
  25. package/dist/backend/backend/src/middleware/body-parser-except.js +33 -0
  26. package/dist/backend/backend/src/middleware/body-parser-except.js.map +1 -0
  27. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  28. package/dist/backend/backend/src/routes/api.routes.js +4 -0
  29. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  30. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  31. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +5 -0
  32. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  33. package/dist/backend/backend/src/services/apps/app-open-link.d.ts +61 -0
  34. package/dist/backend/backend/src/services/apps/app-open-link.d.ts.map +1 -0
  35. package/dist/backend/backend/src/services/apps/app-open-link.js +112 -0
  36. package/dist/backend/backend/src/services/apps/app-open-link.js.map +1 -0
  37. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts +136 -0
  38. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts.map +1 -0
  39. package/dist/backend/backend/src/services/apps/app-wake-message.js +253 -0
  40. package/dist/backend/backend/src/services/apps/app-wake-message.js.map +1 -0
  41. package/dist/backend/backend/src/services/apps/app-wake.service.d.ts +145 -0
  42. package/dist/backend/backend/src/services/apps/app-wake.service.d.ts.map +1 -0
  43. package/dist/backend/backend/src/services/apps/app-wake.service.js +455 -0
  44. package/dist/backend/backend/src/services/apps/app-wake.service.js.map +1 -0
  45. package/dist/backend/backend/src/services/apps/apps-cloud.client.d.ts +74 -0
  46. package/dist/backend/backend/src/services/apps/apps-cloud.client.d.ts.map +1 -0
  47. package/dist/backend/backend/src/services/apps/apps-cloud.client.js +120 -0
  48. package/dist/backend/backend/src/services/apps/apps-cloud.client.js.map +1 -0
  49. package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts +125 -0
  50. package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts.map +1 -0
  51. package/dist/backend/backend/src/services/apps/apps-registry.service.js +192 -0
  52. package/dist/backend/backend/src/services/apps/apps-registry.service.js.map +1 -0
  53. package/dist/backend/backend/src/services/apps/apps.service.d.ts +466 -0
  54. package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -0
  55. package/dist/backend/backend/src/services/apps/apps.service.js +740 -0
  56. package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -0
  57. package/dist/backend/backend/src/services/apps/apps.wiring.d.ts +97 -0
  58. package/dist/backend/backend/src/services/apps/apps.wiring.d.ts.map +1 -0
  59. package/dist/backend/backend/src/services/apps/apps.wiring.js +153 -0
  60. package/dist/backend/backend/src/services/apps/apps.wiring.js.map +1 -0
  61. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  62. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +3 -1
  63. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  64. package/dist/backend/backend/src/services/core/logger.service.d.ts +5 -0
  65. package/dist/backend/backend/src/services/core/logger.service.d.ts.map +1 -1
  66. package/dist/backend/backend/src/services/core/logger.service.js +8 -0
  67. package/dist/backend/backend/src/services/core/logger.service.js.map +1 -1
  68. package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
  69. package/dist/backend/backend/src/services/slack/slack-thread-context.service.js +3 -1
  70. package/dist/backend/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
  71. package/dist/backend/build-info.json +2 -2
  72. package/dist/cli/backend/src/constants.d.ts +115 -0
  73. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  74. package/dist/cli/backend/src/constants.js +115 -0
  75. package/dist/cli/backend/src/constants.js.map +1 -1
  76. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  77. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +5 -0
  78. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  79. package/dist/cli/backend/src/services/apps/app-open-link.d.ts +61 -0
  80. package/dist/cli/backend/src/services/apps/app-open-link.d.ts.map +1 -0
  81. package/dist/cli/backend/src/services/apps/app-open-link.js +112 -0
  82. package/dist/cli/backend/src/services/apps/app-open-link.js.map +1 -0
  83. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  84. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +3 -1
  85. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  86. package/dist/cli/backend/src/services/core/logger.service.d.ts +5 -0
  87. package/dist/cli/backend/src/services/core/logger.service.d.ts.map +1 -1
  88. package/dist/cli/backend/src/services/core/logger.service.js +8 -0
  89. package/dist/cli/backend/src/services/core/logger.service.js.map +1 -1
  90. package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
  91. package/dist/cli/backend/src/services/slack/slack-thread-context.service.js +3 -1
  92. package/dist/cli/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
  93. package/package.json +1 -1
@@ -0,0 +1,740 @@
1
+ /**
2
+ * AppsService — publish, roll back and read/write the data of Crewly Apps
3
+ * on behalf of an agent (or the owner), through {@link AppsCloudClient}.
4
+ *
5
+ * Backs `/api/apps` and the publish-app / app-data skills
6
+ * (specs/2026-10-04-crewly-apps-p2.md).
7
+ *
8
+ * @module services/apps/apps.service
9
+ */
10
+ import path from 'path';
11
+ import { CREWLY_APPS_CONSTANTS } from '../../constants.js';
12
+ import { AppsCloudError } from './apps-cloud.client.js';
13
+ import { toOpenLinkInfos, usableMintedLink } from './app-open-link.js';
14
+ import { sanitizeAppData } from './app-wake-message.js';
15
+ const C = CREWLY_APPS_CONSTANTS;
16
+ const APP_ID_RE = /^[a-km-np-z2-9]{10}$/;
17
+ const COLLECTION_RE = /^[A-Za-z0-9_-]{1,64}$/;
18
+ const DOC_ID_RE = /^[A-Za-z0-9_.:-]{1,128}$/;
19
+ function validation(message) {
20
+ return new AppsCloudError(400, C.ERROR_CODES.VALIDATION, message);
21
+ }
22
+ /**
23
+ * Check an app id's shape.
24
+ *
25
+ * @param appId - Candidate
26
+ * @returns The id
27
+ * @throws AppsCloudError validation
28
+ */
29
+ export function requireAppId(appId) {
30
+ if (typeof appId !== 'string' || !APP_ID_RE.test(appId))
31
+ throw validation('appId must be a 10-character Crewly app id.');
32
+ return appId;
33
+ }
34
+ function requireCollection(c) {
35
+ if (typeof c !== 'string' || !COLLECTION_RE.test(c))
36
+ throw validation('collection must be 1-64 of A-Z a-z 0-9 _ -.');
37
+ return c;
38
+ }
39
+ function requireDocId(id) {
40
+ if (typeof id !== 'string' || !DOC_ID_RE.test(id) || id === '.' || id === '..') {
41
+ throw validation('docId must be 1-128 of A-Z a-z 0-9 _ . : - (and not "." or "..").');
42
+ }
43
+ return id;
44
+ }
45
+ function notYourApp() {
46
+ return new AppsCloudError(403, C.ERROR_CODES.NOT_YOUR_APP, 'This app was published by another agent. Only its publisher can publish or roll it back, and only the publisher and its team can use its data. Ask the owner if you need it.');
47
+ }
48
+ function requireData(data) {
49
+ if (!data || typeof data !== 'object' || Array.isArray(data))
50
+ throw validation('data must be a JSON object.');
51
+ return data;
52
+ }
53
+ function optString(v) {
54
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
55
+ }
56
+ const errorText = (err) => (err instanceof Error ? err.message : String(err));
57
+ /**
58
+ * The app's plain URL (what agents see; opens the sign-in page without a session).
59
+ *
60
+ * @param appId - App id
61
+ * @returns URL
62
+ */
63
+ export function plainAppUrl(appId) {
64
+ return `${C.APPS_ORIGIN}/${appId}`;
65
+ }
66
+ /**
67
+ * The "📱 <name> · Open app" card text. Markdown link: the reply path turns
68
+ * it into a Slack link. A pending public request adds one line asking the
69
+ * owner to approve or decline it in the app.
70
+ *
71
+ * @param name - App name
72
+ * @param url - The link (signed or plain)
73
+ * @param pending - The app's pending public request, if any
74
+ * @returns Card text
75
+ */
76
+ export function appCardText(name, url, pending) {
77
+ const clean = name.replace(/[[\]()\r\n]/g, ' ').replace(/\s+/g, ' ').trim() || 'App';
78
+ const card = `📱 ${clean} · [Open app](${url})`;
79
+ if (!pending)
80
+ return card;
81
+ // Names are validated collection names (P2 regex), safe to print.
82
+ const safe = (xs) => xs.filter((x) => COLLECTION_RE.test(x)).join(', ');
83
+ const parts = [
84
+ pending.publicRead.length ? `read ${safe(pending.publicRead)}` : '',
85
+ pending.publicSubmit.length ? `submit to ${safe(pending.publicSubmit)}` : '',
86
+ ].filter(Boolean);
87
+ return `${card}\n⚠️ Waiting for you: a request to make this app public (anyone with the link could ${parts.join('; ') || 'open it'}). Open the app to approve or decline it.`;
88
+ }
89
+ /**
90
+ * Check a list of collection names for a public request.
91
+ *
92
+ * @param v - Candidate (array of names, or a comma-separated string)
93
+ * @param what - Field name for the message
94
+ * @returns Unique names
95
+ * @throws AppsCloudError validation
96
+ */
97
+ export function requireCollectionList(v, what) {
98
+ if (v === undefined || v === null || v === '')
99
+ return [];
100
+ const raw = typeof v === 'string' ? v.split(',') : v;
101
+ if (!Array.isArray(raw))
102
+ throw validation(`${what} must be a list of collection names.`);
103
+ const names = [...new Set(raw.map((x) => (typeof x === 'string' ? x.trim() : x)).filter((x) => x !== ''))];
104
+ for (const n of names) {
105
+ if (typeof n !== 'string' || !COLLECTION_RE.test(n))
106
+ throw validation(`${what}: "${String(n).slice(0, 70)}" is not a collection name (1-64 of A-Z a-z 0-9 _ -).`);
107
+ }
108
+ if (names.length > C.PUBLIC_REQUEST.MAX_COLLECTIONS)
109
+ throw validation(`${what} can name at most ${C.PUBLIC_REQUEST.MAX_COLLECTIONS} collections.`);
110
+ return names;
111
+ }
112
+ /**
113
+ * Check a public request body.
114
+ *
115
+ * @param input - `{ publicRead?, publicSubmit?, note? }`
116
+ * @returns The body Cloud gets
117
+ * @throws AppsCloudError validation
118
+ */
119
+ export function validatePublicRequest(input) {
120
+ const o = input && typeof input === 'object' && !Array.isArray(input) ? input : null;
121
+ if (!o)
122
+ throw validation('publicRequest must be an object { publicRead?, publicSubmit?, note? }.');
123
+ const publicRead = requireCollectionList(o.publicRead, 'publicRead');
124
+ const publicSubmit = requireCollectionList(o.publicSubmit, 'publicSubmit');
125
+ if (publicRead.length === 0 && publicSubmit.length === 0) {
126
+ throw validation('A public request names at least one collection in publicRead or publicSubmit.');
127
+ }
128
+ if (o.note !== undefined && o.note !== null && typeof o.note !== 'string')
129
+ throw validation('note must be text.');
130
+ const note = optString(o.note);
131
+ if (note && note.length > C.PUBLIC_REQUEST.MAX_NOTE_CHARS)
132
+ throw validation(`note is at most ${C.PUBLIC_REQUEST.MAX_NOTE_CHARS} characters.`);
133
+ return { publicRead, publicSubmit, ...(note ? { note } : {}) };
134
+ }
135
+ /**
136
+ * Check a link lifetime.
137
+ *
138
+ * @param v - Days (number or numeric string), or empty for Cloud's default
139
+ * @returns Days, or undefined
140
+ * @throws AppsCloudError validation
141
+ */
142
+ export function requireTtlDays(v) {
143
+ if (v === undefined || v === null || v === '')
144
+ return undefined;
145
+ const n = typeof v === 'string' ? Number(v) : v;
146
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < C.OPEN_LINK.MIN_TTL_DAYS || n > C.OPEN_LINK.MAX_TTL_DAYS) {
147
+ throw validation(`ttlDays is a whole number of days, ${C.OPEN_LINK.MIN_TTL_DAYS}-${C.OPEN_LINK.MAX_TTL_DAYS}.`);
148
+ }
149
+ return n;
150
+ }
151
+ /**
152
+ * Normalise Cloud's pending request (null when there is none).
153
+ *
154
+ * @param v - Cloud's `publicRequest`
155
+ * @returns The request or null
156
+ */
157
+ function pendingOf(v) {
158
+ if (!v || typeof v !== 'object')
159
+ return null;
160
+ const r = v;
161
+ const list = (x) => (Array.isArray(x) ? x.filter((n) => typeof n === 'string') : []);
162
+ const str = (x) => (typeof x === 'string' ? x : null);
163
+ return { publicRead: list(r.publicRead), publicSubmit: list(r.publicSubmit), note: str(r.note), requestedBy: str(r.requestedBy), requestedAt: str(r.requestedAt) };
164
+ }
165
+ /** The message every public request answers with: only the owner can approve it. */
166
+ export const PUBLIC_REQUEST_MESSAGE = 'Requested: the owner approves it by opening the app. Until then the app stays private; no agent can make it public.';
167
+ /**
168
+ * Said when publishing or rolling back a public app took it private again
169
+ * (crewly-services #33: every new current version of a public app needs the
170
+ * owner's re-approval).
171
+ */
172
+ export const PUBLIC_PAUSED_MESSAGE = 'This app was public. Publishing or rolling back a public app makes it private again until the owner re-approves it in the app (they sign in with Google there to approve). Tell the owner; do not say it is public.';
173
+ /** One blocked word: its display form and the pattern that finds it. */
174
+ const BLOCKED_NAME_PATTERNS = C.PUBLIC_REQUEST.BLOCKED_NAME_WORDS.map((word) => {
175
+ const parts = word.split(/[\s-]+/);
176
+ if (parts.length === 1)
177
+ return { word, re: new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') };
178
+ // "sign in" / "sign-in" / "log in": whole words, any of space, hyphen, underscore or nothing between.
179
+ return { word, re: new RegExp(`\\b${parts.join('[\\s_-]*')}\\b`, 'i') };
180
+ });
181
+ /**
182
+ * The first word Cloud refuses in a public app's name, if any
183
+ * (crewly-services #33; {@link CREWLY_APPS_CONSTANTS.PUBLIC_REQUEST}.BLOCKED_NAME_WORDS).
184
+ *
185
+ * @param name - App name
186
+ * @returns The blocked word found, or null
187
+ */
188
+ export function blockedPublicNameWord(name) {
189
+ for (const { word, re } of BLOCKED_NAME_PATTERNS)
190
+ if (re.test(name))
191
+ return word;
192
+ return null;
193
+ }
194
+ /**
195
+ * Refuse a public request for an app whose name Cloud would refuse at approval.
196
+ *
197
+ * @param name - App name
198
+ * @throws AppsCloudError validation naming the word
199
+ */
200
+ export function assertPublicName(name) {
201
+ const word = blockedPublicNameWord(name);
202
+ if (word) {
203
+ throw validation(`A public app's name may not contain "${word}" (nor any of: ${C.PUBLIC_REQUEST.BLOCKED_NAME_WORDS.join(', ')}); the owner could not approve it. Rename the app (--name) and ask again.`);
204
+ }
205
+ }
206
+ /**
207
+ * Publishing and data access for Crewly Apps.
208
+ */
209
+ export class AppsService {
210
+ deps;
211
+ constructor(deps) {
212
+ this.deps = deps;
213
+ }
214
+ /**
215
+ * Publish a bundle: find the caller's app (or create one), upload a new
216
+ * version, record it locally, and optionally post the Slack card.
217
+ *
218
+ * @param input - Files and options from the skill
219
+ * @param caller - Agent session or owner
220
+ * @returns appId, url, version, whether it was created / notified
221
+ * @throws AppsCloudError on validation or a Cloud failure
222
+ */
223
+ async publish(input, caller) {
224
+ const files = this.validateFiles(input.files);
225
+ const explicitId = input.appId !== undefined && input.appId !== null && input.appId !== '' ? requireAppId(input.appId) : undefined;
226
+ const source = optString(input.source) ?? null;
227
+ const name = optString(input.name);
228
+ const agentSession = caller.agentSession ?? null;
229
+ if (name && name.length > 80)
230
+ throw validation('name is at most 80 characters.');
231
+ const publicBody = input.publicRequest === undefined || input.publicRequest === null ? null : validatePublicRequest(input.publicRequest);
232
+ if (publicBody && name)
233
+ assertPublicName(name);
234
+ const { registry, client } = this.deps;
235
+ let entry = await registry.find({ appId: explicitId, agentSession, source, name });
236
+ // An agent publishes only to its own apps; adopting an app made elsewhere is the owner's call.
237
+ if (explicitId && agentSession && (!entry || entry.agentSession !== agentSession))
238
+ throw notYourApp();
239
+ let app = null;
240
+ let created = false;
241
+ if (entry && !explicitId) {
242
+ // Found by source/name: if the owner deleted it, publish a new app instead.
243
+ try {
244
+ app = await client.request('GET', `/apps/${entry.appId}`, { agent: caller.agentSession });
245
+ }
246
+ catch (err) {
247
+ if (!(err instanceof AppsCloudError) || err.status !== 404)
248
+ throw err;
249
+ await registry.markDeleted(entry.appId);
250
+ entry = null;
251
+ }
252
+ }
253
+ else if (entry) {
254
+ app = await client.request('GET', `/apps/${entry.appId}`, { agent: caller.agentSession });
255
+ }
256
+ if (entry && app) {
257
+ if (name && name !== app.name) {
258
+ app = await client.request('PATCH', `/apps/${app.appId}`, { body: { name }, agent: caller.agentSession });
259
+ }
260
+ }
261
+ else if (explicitId) {
262
+ // An app made elsewhere (another machine, the portal): adopt it.
263
+ app = await client.request('GET', `/apps/${explicitId}`, { agent: caller.agentSession });
264
+ }
265
+ else {
266
+ const newName = name ?? (source ? path.basename(source).replace(/\.[^.]+$/, '') : '');
267
+ // Checked before the app is created, so a refused name leaves nothing behind.
268
+ if (publicBody)
269
+ assertPublicName(newName.slice(0, 80) || 'App');
270
+ app = await client.request('POST', '/apps', { body: { name: newName.slice(0, 80) || 'App' }, agent: caller.agentSession });
271
+ created = true;
272
+ }
273
+ if (publicBody)
274
+ assertPublicName(app.name);
275
+ const wasPublic = app.visibility === 'public';
276
+ const entryFile = optString(input.entry);
277
+ const note = optString(input.note);
278
+ const version = await client.request('POST', `/apps/${app.appId}/versions`, {
279
+ body: {
280
+ files,
281
+ ...(entryFile ? { entry: entryFile } : {}),
282
+ ...(note ? { note: note.slice(0, 200) } : {}),
283
+ },
284
+ agent: caller.agentSession,
285
+ });
286
+ entry = await registry.upsert(app.appId, {
287
+ name: app.name,
288
+ url: app.url,
289
+ // The last agent to publish owns the wake; an owner publish keeps it.
290
+ ...(agentSession ? { agentSession } : {}),
291
+ ...(source ? { source } : {}),
292
+ currentVersion: version.version,
293
+ // A brand-new app has no history: start right before its first change,
294
+ // so an owner edit made before the first poll is not skipped.
295
+ ...(created ? { cursor: 0 } : {}),
296
+ deleted: false,
297
+ });
298
+ // crewly-services #33: a new version of a public app takes it private
299
+ // with a pending re-approval. Re-read the app (one GET, only when it was public).
300
+ let publicPaused = false;
301
+ if (wasPublic) {
302
+ const after = await client.request('GET', `/apps/${app.appId}`, { agent: caller.agentSession }).catch(() => null);
303
+ if (after) {
304
+ publicPaused = after.visibility === 'private' && !!pendingOf(after.publicRequest);
305
+ app = { ...app, visibility: after.visibility, publicRequest: after.publicRequest };
306
+ }
307
+ }
308
+ // P3: ask the owner to make it public. The version is already up, so a
309
+ // failure here is reported, not thrown.
310
+ let pending = pendingOf(app.publicRequest);
311
+ let publicRequested;
312
+ let publicError;
313
+ if (publicBody) {
314
+ try {
315
+ const v = await client.request('POST', `/apps/${app.appId}/visibility-request`, {
316
+ body: publicBody,
317
+ agent: caller.agentSession,
318
+ });
319
+ pending = pendingOf(v?.publicRequest) ?? { ...publicBody, note: publicBody.note ?? null, requestedBy: caller.agentSession ?? null, requestedAt: null };
320
+ publicRequested = true;
321
+ }
322
+ catch (err) {
323
+ publicRequested = false;
324
+ publicError = errorText(err);
325
+ }
326
+ }
327
+ // An agent's public request always tells the owner, so the card goes out with it.
328
+ const card = input.notify === true || (publicRequested === true && !!caller.agentSession)
329
+ ? await this.sendCard({ appId: app.appId, name: app.name, pending }, caller, entry.agentSession ?? null)
330
+ : { notified: false };
331
+ return {
332
+ appId: app.appId,
333
+ name: app.name,
334
+ url: plainAppUrl(app.appId),
335
+ version: version.version,
336
+ created,
337
+ ...card,
338
+ ...(publicRequested !== undefined ? { publicRequested } : {}),
339
+ ...(publicError ? { publicError } : {}),
340
+ ...(publicPaused ? { publicPaused: true, publicPausedMessage: PUBLIC_PAUSED_MESSAGE } : {}),
341
+ };
342
+ }
343
+ /**
344
+ * Post the "Open app" card for the owner.
345
+ *
346
+ * Destination rule (P3 §1): when the agent has a DM with the owner, a
347
+ * fresh signed link is minted and the card goes to that DM only — never
348
+ * to a room, a team channel or a shared room, wherever the conversation
349
+ * happens to be. Without a DM, or when minting or the DM post fails, the
350
+ * card carries the plain URL and goes where the agent talks with the owner
351
+ * (P2). The signed URL is never returned.
352
+ *
353
+ * @param app - App id, name and its pending public request
354
+ * @param caller - Agent or owner
355
+ * @param publisher - The app's recorded agent (posts the card for an owner call)
356
+ * @param ttlDays - Link lifetime (Cloud's default when omitted)
357
+ * @returns What was posted, without the URL
358
+ */
359
+ async sendCard(app, caller, publisher, ttlDays) {
360
+ const poster = caller.agentSession ?? publisher;
361
+ if (!poster)
362
+ return { notified: false, notifyError: 'Only an agent can post the app card (it goes out under the agent’s identity), and this app has no recorded agent.' };
363
+ const cards = this.deps.cards;
364
+ if (!cards)
365
+ return { notified: false, notifyError: 'Posting the app card is not available on this instance.' };
366
+ let linkError;
367
+ const dm = await cards.ownerDm(poster).catch(() => null);
368
+ if (dm) {
369
+ let link = null;
370
+ try {
371
+ link = usableMintedLink(app.appId, await this.deps.client.request('POST', `/apps/${app.appId}/open-links`, { body: ttlDays ? { ttlDays } : {}, agent: caller.agentSession }));
372
+ if (!link)
373
+ linkError = 'Crewly Cloud did not return a usable open-link; the card has the plain URL (the owner may have to sign in).';
374
+ }
375
+ catch (err) {
376
+ linkError = `Could not mint a signed link (${errorText(err)}); the card has the plain URL (the owner may have to sign in).`;
377
+ }
378
+ if (link) {
379
+ const r = await cards.postToOwnerDm(poster, dm, appCardText(app.name, link.url, app.pending)).catch((err) => ({ ok: false, error: errorText(err) }));
380
+ if (r.ok) {
381
+ return { notified: true, card: 'signed', cardPlace: 'owner-dm', linkId: link.linkId, ...(link.expiresAt ? { linkExpiresAt: link.expiresAt } : {}) };
382
+ }
383
+ // Never leave a live link nobody received.
384
+ await this.deps.client.request('DELETE', `/apps/${app.appId}/open-links/${link.linkId}`, { agent: caller.agentSession }).catch(() => undefined);
385
+ linkError = `The signed card could not be posted to the DM with the owner (${r.error ?? 'not delivered'}); sent the plain URL instead.`;
386
+ }
387
+ }
388
+ else {
389
+ linkError = 'No DM with the owner to put a signed link in; the card has the plain URL (the owner may have to sign in).';
390
+ }
391
+ const r = await cards.postReply(poster, appCardText(app.name, plainAppUrl(app.appId), app.pending)).catch((err) => ({ ok: false, error: errorText(err) }));
392
+ return {
393
+ notified: r.ok,
394
+ ...(r.ok ? { card: 'plain', cardPlace: 'conversation' } : { notifyError: r.error ?? 'The card could not be delivered.' }),
395
+ ...(linkError ? { linkError } : {}),
396
+ };
397
+ }
398
+ /**
399
+ * Post the card again with a fresh signed link, without publishing (P3 §1, re-share).
400
+ *
401
+ * @param appId - App id
402
+ * @param opts - `ttlDays` (1-30, Cloud's default 7)
403
+ * @param caller - Agent (its own apps) or owner (any; posts as the recorded agent)
404
+ * @returns What was posted; never the signed URL
405
+ */
406
+ async share(appId, opts, caller) {
407
+ const id = requireAppId(appId);
408
+ const ttlDays = requireTtlDays(opts.ttlDays);
409
+ await this.assertPublisher(id, caller);
410
+ const app = await this.deps.client.request('GET', `/apps/${id}`, { agent: caller.agentSession });
411
+ const pending = pendingOf(app.publicRequest);
412
+ const entry = await this.deps.registry.get(id);
413
+ const card = await this.sendCard({ appId: id, name: app.name, pending }, caller, entry?.agentSession ?? null, ttlDays);
414
+ return { appId: id, name: app.name, url: plainAppUrl(id), visibility: app.visibility ?? 'private', publicRequestPending: !!pending, ...card };
415
+ }
416
+ /**
417
+ * The app's open-links (no tokens).
418
+ *
419
+ * @param appId - App id
420
+ * @param caller - Agent (its own apps) or owner
421
+ * @returns Links, newest as Cloud orders them
422
+ */
423
+ async links(appId, caller) {
424
+ const id = requireAppId(appId);
425
+ await this.assertPublisher(id, caller);
426
+ return toOpenLinkInfos(await this.deps.client.request('GET', `/apps/${id}/open-links`, { agent: caller.agentSession }));
427
+ }
428
+ /**
429
+ * Revoke one open-link.
430
+ *
431
+ * @param appId - App id
432
+ * @param linkId - Link id
433
+ * @param caller - Agent (its own apps) or owner
434
+ * @returns `{ revoked: true }`; 404 not_found for an unknown link
435
+ */
436
+ async revokeLink(appId, linkId, caller) {
437
+ const id = requireAppId(appId);
438
+ if (typeof linkId !== 'string' || !C.OPEN_LINK.LINK_ID_PATTERN.test(linkId))
439
+ throw validation('linkId must be 1-64 of A-Z a-z 0-9 _ -.');
440
+ await this.assertPublisher(id, caller);
441
+ const r = await this.deps.client.request('DELETE', `/apps/${id}/open-links/${linkId}`, { agent: caller.agentSession });
442
+ return { revoked: r?.revoked === true };
443
+ }
444
+ /**
445
+ * Revoke every open-link of an app.
446
+ *
447
+ * @param appId - App id
448
+ * @param caller - Agent (its own apps) or owner
449
+ * @returns `{ revoked: <count> }`
450
+ */
451
+ async revokeLinks(appId, caller) {
452
+ const id = requireAppId(appId);
453
+ await this.assertPublisher(id, caller);
454
+ const r = await this.deps.client.request('DELETE', `/apps/${id}/open-links`, { agent: caller.agentSession });
455
+ return { revoked: typeof r?.revoked === 'number' ? r.revoked : 0 };
456
+ }
457
+ /**
458
+ * Ask the owner to make an app public (P3 §2). Only records the request:
459
+ * Cloud flips visibility only from the owner's own session in the apps
460
+ * shell, so nothing here can make an app public. An agent's request also
461
+ * posts the card, which tells the owner to approve or decline it.
462
+ *
463
+ * @param appId - App id
464
+ * @param input - Collections to open for reading / anonymous submissions, and a note
465
+ * @param caller - Agent (its own apps) or owner
466
+ * @returns The recorded request and the card result
467
+ */
468
+ async requestPublic(appId, input, caller) {
469
+ const id = requireAppId(appId);
470
+ const body = validatePublicRequest(input);
471
+ await this.assertPublisher(id, caller);
472
+ const current = await this.deps.client.request('GET', `/apps/${id}`, { agent: caller.agentSession });
473
+ assertPublicName(current.name);
474
+ const v = await this.deps.client.request('POST', `/apps/${id}/visibility-request`, {
475
+ body,
476
+ agent: caller.agentSession,
477
+ });
478
+ const pending = pendingOf(v?.publicRequest);
479
+ let card = { notified: false };
480
+ if (caller.agentSession) {
481
+ const entry = await this.deps.registry.get(id);
482
+ card = await this.sendCard({ appId: id, name: current.name || entry?.name || 'App', pending: pending ?? { ...body, note: body.note ?? null, requestedBy: caller.agentSession, requestedAt: null } }, caller, entry?.agentSession ?? null);
483
+ }
484
+ return { appId: id, url: plainAppUrl(id), visibility: v?.visibility ?? 'private', publicRequest: pending, message: PUBLIC_REQUEST_MESSAGE, ...card };
485
+ }
486
+ /**
487
+ * Withdraw a pending public request.
488
+ *
489
+ * @param appId - App id
490
+ * @param caller - Agent (its own apps) or owner
491
+ * @returns Cloud's answer
492
+ */
493
+ async cancelPublicRequest(appId, caller) {
494
+ const id = requireAppId(appId);
495
+ await this.assertPublisher(id, caller);
496
+ const r = await this.deps.client.request('DELETE', `/apps/${id}/visibility-request`, { agent: caller.agentSession });
497
+ return { appId: id, cancelled: true, visibility: r?.visibility ?? 'private' };
498
+ }
499
+ /**
500
+ * Make an app private again (instant; always allowed).
501
+ *
502
+ * @param appId - App id
503
+ * @param caller - Agent (its own apps) or owner
504
+ * @returns `{ visibility: 'private' }`
505
+ */
506
+ async makePrivate(appId, caller) {
507
+ const id = requireAppId(appId);
508
+ await this.assertPublisher(id, caller);
509
+ const r = await this.deps.client.request('POST', `/apps/${id}/make-private`, { agent: caller.agentSession });
510
+ return { appId: id, visibility: r?.visibility ?? 'private' };
511
+ }
512
+ /**
513
+ * Make an earlier retained version current.
514
+ *
515
+ * @param appId - App id
516
+ * @param version - Version number
517
+ * @param caller - Agent or owner
518
+ * @returns The app after rollback
519
+ */
520
+ async rollback(appId, version, caller) {
521
+ const id = requireAppId(appId);
522
+ const v = typeof version === 'string' ? Number(version) : version;
523
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1)
524
+ throw validation('version must be a positive integer.');
525
+ await this.assertPublisher(id, caller);
526
+ // Was it public? A rollback of a public app takes it private (crewly-services #33).
527
+ const before = await this.deps.client.request('GET', `/apps/${id}`, { agent: caller.agentSession }).catch(() => null);
528
+ const app = await this.deps.client.request('POST', `/apps/${id}/rollback`, { body: { version: v }, agent: caller.agentSession });
529
+ if (await this.deps.registry.get(id))
530
+ await this.deps.registry.upsert(id, { currentVersion: app.currentVersion });
531
+ let after = app;
532
+ if (before?.visibility === 'public' && app.visibility === undefined) {
533
+ after = await this.deps.client.request('GET', `/apps/${id}`, { agent: caller.agentSession }).catch(() => null);
534
+ }
535
+ const paused = before?.visibility === 'public' && after?.visibility === 'private' && !!pendingOf(after.publicRequest);
536
+ return paused ? { ...app, publicPaused: true, publicPausedMessage: PUBLIC_PAUSED_MESSAGE } : app;
537
+ }
538
+ /**
539
+ * Versions of an app, newest first.
540
+ *
541
+ * @param appId - App id
542
+ * @param caller - Agent or owner
543
+ * @returns Versions
544
+ */
545
+ async versions(appId, caller) {
546
+ const id = requireAppId(appId);
547
+ await this.assertPublisher(id, caller);
548
+ return this.deps.client.request('GET', `/apps/${id}/versions`, { agent: caller.agentSession });
549
+ }
550
+ /**
551
+ * Agents may manage (publish, roll back, list versions of) only apps they
552
+ * published. The owner may manage any.
553
+ *
554
+ * @param appId - App id
555
+ * @param caller - Agent or owner
556
+ * @throws AppsCloudError 403 not_your_app
557
+ */
558
+ async assertPublisher(appId, caller) {
559
+ if (!caller.agentSession)
560
+ return;
561
+ const entry = await this.deps.registry.get(appId);
562
+ if (!entry || entry.agentSession !== caller.agentSession)
563
+ throw notYourApp();
564
+ }
565
+ /**
566
+ * Agents may read and write the data of apps they published, or that a
567
+ * teammate published. The owner may use any.
568
+ *
569
+ * @param appId - App id
570
+ * @param caller - Agent or owner
571
+ * @throws AppsCloudError 403 not_your_app
572
+ */
573
+ async assertDataAccess(appId, caller) {
574
+ const me = caller.agentSession;
575
+ if (!me)
576
+ return;
577
+ const entry = await this.deps.registry.get(appId);
578
+ const publisher = entry?.agentSession;
579
+ if (!publisher)
580
+ throw notYourApp();
581
+ if (publisher === me)
582
+ return;
583
+ if (this.deps.sameTeam && (await this.deps.sameTeam(me, publisher).catch(() => false)))
584
+ return;
585
+ throw notYourApp();
586
+ }
587
+ /**
588
+ * Apps this instance published (local registry, not Cloud).
589
+ *
590
+ * @returns Entries without the poller cursor
591
+ */
592
+ async list(caller = {}) {
593
+ return (await this.deps.registry.list())
594
+ .filter((e) => !caller.agentSession || e.agentSession === caller.agentSession)
595
+ .map(({ cursor: _cursor, delivered: _delivered, wakes: _wakes, visitorWakes: _visitorWakes, ...rest }) => rest);
596
+ }
597
+ /**
598
+ * One page of a collection.
599
+ *
600
+ * @param appId - App id
601
+ * @param collection - Collection
602
+ * @param opts - limit / after
603
+ * @param caller - Agent or owner
604
+ * @returns `{ docs, next }`
605
+ */
606
+ async listDocs(appId, collection, opts, caller) {
607
+ const limit = opts.limit !== undefined && opts.limit !== '' ? Number(opts.limit) : undefined;
608
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 500))
609
+ throw validation('limit is 1-500.');
610
+ const after = opts.after !== undefined && opts.after !== '' ? requireDocId(opts.after) : undefined;
611
+ const path = `/apps/${requireAppId(appId)}/data/${requireCollection(collection)}`;
612
+ await this.assertDataAccess(appId, caller);
613
+ return this.dataRequest('GET', path, {
614
+ query: { limit, after },
615
+ agent: caller.agentSession,
616
+ });
617
+ }
618
+ /**
619
+ * One document (`{ id, data, rev, … }`); a missing one throws 404 not_found.
620
+ *
621
+ * @param appId - App id
622
+ * @param collection - Collection
623
+ * @param docId - Document id
624
+ * @param caller - Agent or owner
625
+ * @returns The document
626
+ */
627
+ async getDoc(appId, collection, docId, caller) {
628
+ const path = this.docPath(appId, collection, docId);
629
+ await this.assertDataAccess(appId, caller);
630
+ return this.dataRequest('GET', path, { agent: caller.agentSession });
631
+ }
632
+ /**
633
+ * Replace (or create) a document.
634
+ *
635
+ * @param appId - App id
636
+ * @param collection - Collection
637
+ * @param docId - Document id
638
+ * @param data - New content
639
+ * @param caller - Agent or owner
640
+ * @returns The stored document
641
+ */
642
+ async setDoc(appId, collection, docId, data, caller) {
643
+ const path = this.docPath(appId, collection, docId);
644
+ const body = { data: requireData(data) };
645
+ await this.assertDataAccess(appId, caller);
646
+ return this.dataRequest('PUT', path, { body, agent: caller.agentSession });
647
+ }
648
+ /**
649
+ * Shallow-merge into a document; `ifRev` makes it conditional (409 conflict).
650
+ *
651
+ * @param appId - App id
652
+ * @param collection - Collection
653
+ * @param docId - Document id
654
+ * @param data - Fields to merge
655
+ * @param ifRev - Expected current rev (optional)
656
+ * @param caller - Agent or owner
657
+ * @returns The stored document
658
+ */
659
+ async updateDoc(appId, collection, docId, data, ifRev, caller) {
660
+ const rev = ifRev === undefined || ifRev === null || ifRev === '' ? undefined : Number(ifRev);
661
+ if (rev !== undefined && (!Number.isInteger(rev) || rev < 0))
662
+ throw validation('ifRev must be a non-negative integer.');
663
+ const path = this.docPath(appId, collection, docId);
664
+ const body = { data: requireData(data), ...(rev !== undefined ? { ifRev: rev } : {}) };
665
+ await this.assertDataAccess(appId, caller);
666
+ return this.dataRequest('PATCH', path, {
667
+ body,
668
+ agent: caller.agentSession,
669
+ });
670
+ }
671
+ /**
672
+ * Add a document with a generated id.
673
+ *
674
+ * @param appId - App id
675
+ * @param collection - Collection
676
+ * @param data - Content
677
+ * @param caller - Agent or owner
678
+ * @returns The stored document
679
+ */
680
+ async addDoc(appId, collection, data, caller) {
681
+ const path = `/apps/${requireAppId(appId)}/data/${requireCollection(collection)}`;
682
+ const body = { data: requireData(data) };
683
+ await this.assertDataAccess(appId, caller);
684
+ return this.dataRequest('POST', path, {
685
+ body,
686
+ agent: caller.agentSession,
687
+ });
688
+ }
689
+ /**
690
+ * Delete a document.
691
+ *
692
+ * @param appId - App id
693
+ * @param collection - Collection
694
+ * @param docId - Document id
695
+ * @param caller - Agent or owner
696
+ * @returns `{ deleted: true }`
697
+ */
698
+ async deleteDoc(appId, collection, docId, caller) {
699
+ const path = this.docPath(appId, collection, docId);
700
+ await this.assertDataAccess(appId, caller);
701
+ return this.dataRequest('DELETE', path, { agent: caller.agentSession });
702
+ }
703
+ /**
704
+ * A Cloud data call whose answer is sanitised before it leaves the backend
705
+ * (P3 §4): documents were written by the owner, other agents or anonymous
706
+ * visitors, and an agent prints them into its terminal, where harness
707
+ * markers (`[CHAT_RESPONSE]`, `[DONE]`, fenced blocks), ANSI escapes or
708
+ * bidi characters would act. Every string value and key is cleaned; the
709
+ * structure is kept. Done here, not in the skill, so no caller bypasses it.
710
+ *
711
+ * @param method - HTTP method
712
+ * @param path - Cloud path
713
+ * @param opts - Request options
714
+ * @returns The display-safe answer
715
+ */
716
+ async dataRequest(method, path, opts) {
717
+ return sanitizeAppData(await this.deps.client.request(method, path, opts));
718
+ }
719
+ docPath(appId, collection, docId) {
720
+ return `/apps/${requireAppId(appId)}/data/${requireCollection(collection)}/${encodeURIComponent(requireDocId(docId))}`;
721
+ }
722
+ validateFiles(files) {
723
+ if (!Array.isArray(files) || files.length === 0)
724
+ throw validation('files must be a non-empty array of { path, contentBase64 }.');
725
+ return files.map((f, i) => {
726
+ const o = (f ?? {});
727
+ if (typeof o.path !== 'string' || !o.path || o.path.startsWith('/') || o.path.split('/').includes('..')) {
728
+ throw validation(`files[${i}].path must be a relative path inside the bundle.`);
729
+ }
730
+ if (typeof o.contentBase64 !== 'string')
731
+ throw validation(`files[${i}].contentBase64 is required.`);
732
+ return {
733
+ path: o.path,
734
+ contentBase64: o.contentBase64,
735
+ ...(typeof o.contentType === 'string' && o.contentType ? { contentType: o.contentType } : {}),
736
+ };
737
+ });
738
+ }
739
+ }
740
+ //# sourceMappingURL=apps.service.js.map