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.
- package/config/skills/agent/core/app-data/SKILL.md +117 -0
- package/config/skills/agent/core/app-data/execute.sh +141 -0
- package/config/skills/agent/core/app-data/execute.test.sh +108 -0
- package/config/skills/agent/core/publish-app/SKILL.md +248 -0
- package/config/skills/agent/core/publish-app/execute.sh +302 -0
- package/config/skills/agent/core/publish-app/execute.test.sh +205 -0
- package/dist/backend/backend/src/constants.d.ts +115 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +115 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts +55 -0
- package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/apps/apps.controller.js +131 -0
- package/dist/backend/backend/src/controllers/apps/apps.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts +53 -0
- package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/apps/apps.routes.js +108 -0
- package/dist/backend/backend/src/controllers/apps/apps.routes.js.map +1 -0
- package/dist/backend/backend/src/index.d.ts +1 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +57 -5
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/middleware/body-parser-except.d.ts +25 -0
- package/dist/backend/backend/src/middleware/body-parser-except.d.ts.map +1 -0
- package/dist/backend/backend/src/middleware/body-parser-except.js +33 -0
- package/dist/backend/backend/src/middleware/body-parser-except.js.map +1 -0
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +4 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +5 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/apps/app-open-link.d.ts +61 -0
- package/dist/backend/backend/src/services/apps/app-open-link.d.ts.map +1 -0
- package/dist/backend/backend/src/services/apps/app-open-link.js +112 -0
- package/dist/backend/backend/src/services/apps/app-open-link.js.map +1 -0
- package/dist/backend/backend/src/services/apps/app-wake-message.d.ts +136 -0
- package/dist/backend/backend/src/services/apps/app-wake-message.d.ts.map +1 -0
- package/dist/backend/backend/src/services/apps/app-wake-message.js +253 -0
- package/dist/backend/backend/src/services/apps/app-wake-message.js.map +1 -0
- package/dist/backend/backend/src/services/apps/app-wake.service.d.ts +145 -0
- package/dist/backend/backend/src/services/apps/app-wake.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/apps/app-wake.service.js +455 -0
- package/dist/backend/backend/src/services/apps/app-wake.service.js.map +1 -0
- package/dist/backend/backend/src/services/apps/apps-cloud.client.d.ts +74 -0
- package/dist/backend/backend/src/services/apps/apps-cloud.client.d.ts.map +1 -0
- package/dist/backend/backend/src/services/apps/apps-cloud.client.js +120 -0
- package/dist/backend/backend/src/services/apps/apps-cloud.client.js.map +1 -0
- package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts +125 -0
- package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/apps/apps-registry.service.js +192 -0
- package/dist/backend/backend/src/services/apps/apps-registry.service.js.map +1 -0
- package/dist/backend/backend/src/services/apps/apps.service.d.ts +466 -0
- package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/apps/apps.service.js +740 -0
- package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -0
- package/dist/backend/backend/src/services/apps/apps.wiring.d.ts +97 -0
- package/dist/backend/backend/src/services/apps/apps.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/apps/apps.wiring.js +153 -0
- package/dist/backend/backend/src/services/apps/apps.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +3 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
- package/dist/backend/backend/src/services/core/logger.service.d.ts +5 -0
- package/dist/backend/backend/src/services/core/logger.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/core/logger.service.js +8 -0
- package/dist/backend/backend/src/services/core/logger.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.js +3 -1
- package/dist/backend/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +115 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +115 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +5 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/cli/backend/src/services/apps/app-open-link.d.ts +61 -0
- package/dist/cli/backend/src/services/apps/app-open-link.d.ts.map +1 -0
- package/dist/cli/backend/src/services/apps/app-open-link.js +112 -0
- package/dist/cli/backend/src/services/apps/app-open-link.js.map +1 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +3 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
- package/dist/cli/backend/src/services/core/logger.service.d.ts +5 -0
- package/dist/cli/backend/src/services/core/logger.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/core/logger.service.js +8 -0
- package/dist/cli/backend/src/services/core/logger.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.js +3 -1
- package/dist/cli/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
- 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
|