@serve.zone/interfaces 21.1.0 → 23.0.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 (83) hide show
  1. package/changelog.md +23 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/appstore/types.d.ts +31 -16
  4. package/dist_ts/appstore/types.js +208 -11
  5. package/dist_ts/data/coremail.d.ts +83 -9
  6. package/dist_ts/data/coremail.js +67 -1
  7. package/dist_ts/data/coremail.runtime.d.ts +27 -1
  8. package/dist_ts/data/coremail.runtime.js +800 -36
  9. package/dist_ts/data/deploymentoperation.js +6 -20
  10. package/dist_ts/data/deploymentpreflight.d.ts +2 -6
  11. package/dist_ts/data/deploymentpreflight.js +1 -1
  12. package/dist_ts/data/hostedapp.d.ts +40 -14
  13. package/dist_ts/data/hostedapp.js +90 -1
  14. package/dist_ts/data/immutableimage.d.ts +30 -1
  15. package/dist_ts/data/immutableimage.js +103 -1
  16. package/dist_ts/data/index.d.ts +0 -2
  17. package/dist_ts/data/index.js +1 -3
  18. package/dist_ts/data/secret.d.ts +186 -13
  19. package/dist_ts/data/secret.js +543 -45
  20. package/dist_ts/data/service.d.ts +0 -39
  21. package/dist_ts/data/service.js +1 -77
  22. package/dist_ts/data/settings.d.ts +62 -43
  23. package/dist_ts/data/settings.js +34 -2
  24. package/dist_ts/platform/storage.d.ts +13 -14
  25. package/dist_ts/platform/storage.js +1 -1
  26. package/dist_ts/platform/storagemigration.d.ts +8 -8
  27. package/dist_ts/platform/storagemigration.golden.d.ts +2 -2
  28. package/dist_ts/platform/storagemigration.golden.js +19 -88
  29. package/dist_ts/platform/storagemigration.js +46 -58
  30. package/dist_ts/platform/types.d.ts +5 -19
  31. package/dist_ts/plugins.d.ts +2 -1
  32. package/dist_ts/plugins.js +3 -2
  33. package/dist_ts/requests/coremail.d.ts +12 -5
  34. package/dist_ts/requests/hostedapp.d.ts +12 -9
  35. package/dist_ts/requests/hostedapp.js +38 -1
  36. package/dist_ts/requests/index.d.ts +1 -3
  37. package/dist_ts/requests/index.js +2 -4
  38. package/dist_ts/requests/platform.d.ts +0 -1
  39. package/dist_ts/requests/secret.d.ts +23 -17
  40. package/dist_ts/requests/secret.js +161 -2
  41. package/dist_ts/requests/service.d.ts +0 -13
  42. package/dist_ts/requests/settings.d.ts +3 -3
  43. package/dist_ts/runtime.d.ts +78 -26
  44. package/dist_ts/runtime.js +219 -19
  45. package/package.json +2 -1
  46. package/readme.md +163 -38
  47. package/ts/00_commitinfo_data.ts +1 -1
  48. package/ts/appstore/types.ts +251 -25
  49. package/ts/data/coremail.runtime.ts +1212 -38
  50. package/ts/data/coremail.ts +120 -8
  51. package/ts/data/deploymentoperation.ts +7 -31
  52. package/ts/data/deploymentpreflight.ts +2 -5
  53. package/ts/data/hostedapp.ts +144 -18
  54. package/ts/data/immutableimage.ts +150 -1
  55. package/ts/data/index.ts +0 -2
  56. package/ts/data/secret.ts +775 -53
  57. package/ts/data/service.ts +0 -118
  58. package/ts/data/settings.ts +70 -80
  59. package/ts/platform/storage.ts +15 -16
  60. package/ts/platform/storagemigration.golden.ts +18 -95
  61. package/ts/platform/storagemigration.ts +57 -87
  62. package/ts/platform/types.ts +6 -19
  63. package/ts/plugins.ts +2 -0
  64. package/ts/requests/coremail.ts +13 -4
  65. package/ts/requests/hostedapp.ts +50 -9
  66. package/ts/requests/index.ts +0 -4
  67. package/ts/requests/platform.ts +0 -1
  68. package/ts/requests/secret.ts +204 -9
  69. package/ts/requests/service.ts +0 -16
  70. package/ts/requests/settings.ts +3 -3
  71. package/ts/runtime.ts +314 -41
  72. package/dist_ts/data/secretbundle.d.ts +0 -57
  73. package/dist_ts/data/secretbundle.js +0 -2
  74. package/dist_ts/data/secretgroup.d.ts +0 -52
  75. package/dist_ts/data/secretgroup.js +0 -2
  76. package/dist_ts/requests/secretbundle.d.ts +0 -75
  77. package/dist_ts/requests/secretbundle.js +0 -4
  78. package/dist_ts/requests/secretgroup.d.ts +0 -52
  79. package/dist_ts/requests/secretgroup.js +0 -4
  80. package/ts/data/secretbundle.ts +0 -65
  81. package/ts/data/secretgroup.ts +0 -58
  82. package/ts/requests/secretbundle.ts +0 -103
  83. package/ts/requests/secretgroup.ts +0 -74
@@ -10,7 +10,7 @@
10
10
  export type TCoreMailSha256 = `sha256:${string}`;
11
11
 
12
12
  export type TCoreMailCapability = 'outbound' | 'inbound';
13
- export type TCoreMailBindingState = 'active' | 'disabled';
13
+ export type TCoreMailBindingState = 'active' | 'draining' | 'disabled';
14
14
  export type TCoreMailCredentialState = 'current' | 'retiring';
15
15
  export type TCoreMailCredentialVerifierFormat = 'argon2id-v1';
16
16
  export type TCoreMailPartKind = 'text' | 'html' | 'attachment';
@@ -30,6 +30,16 @@ export type TCoreMailInboundDeliveryState = 'pending' | 'fetching' | 'fetched' |
30
30
  export type TCoreMailInboundAckOutcome = 'processed' | 'discarded';
31
31
  export type TCoreMailRecipientAction = 'accept' | 'reject' | 'defer';
32
32
  export type TCoreMailReconciliationState = 'applying' | 'ready' | 'failed';
33
+ export type TCoreMailWorkloadOperation =
34
+ | 'coreMailPrepareOutboundSubmission'
35
+ | 'coreMailPrepareOutboundPartUpload'
36
+ | 'coreMailCompleteOutboundPartUpload'
37
+ | 'coreMailFinalizeOutboundSubmission'
38
+ | 'coreMailGetOutboundSubmission'
39
+ | 'coreMailListInboundDeliveries'
40
+ | 'coreMailPrepareInboundFetch'
41
+ | 'coreMailCompleteInboundFetch'
42
+ | 'coreMailAcknowledgeInboundDelivery';
33
43
 
34
44
  export type TCoreMailErrorCode =
35
45
  | 'AUTHENTICATION_FAILED'
@@ -43,6 +53,7 @@ export type TCoreMailErrorCode =
43
53
  | 'INVALID_RECIPIENT'
44
54
  | 'INVALID_HEADER'
45
55
  | 'PAYLOAD_LIMIT_EXCEEDED'
56
+ | 'QUOTA_EXCEEDED'
46
57
  | 'IDEMPOTENCY_CONFLICT'
47
58
  | 'STATE_CONFLICT'
48
59
  | 'TRANSFER_GRANT_EXPIRED'
@@ -52,6 +63,9 @@ export type TCoreMailErrorCode =
52
63
  | 'TRANSFER_ENCODING_UNSUPPORTED'
53
64
  | 'OBJECT_INTEGRITY_CONFLICT'
54
65
  | 'DELIVERY_NOT_FETCHED'
66
+ | 'DELIVERY_DEFERRED'
67
+ | 'DELIVERY_FAILED'
68
+ | 'DELIVERY_DEAD_LETTERED'
55
69
  | 'GATEWAY_UNAVAILABLE'
56
70
  | 'RECONCILIATION_FENCE_MISMATCH';
57
71
 
@@ -111,6 +125,7 @@ export interface ICoreMailTransferGrant {
111
125
  sha256: TCoreMailSha256;
112
126
  lengthBytes: number;
113
127
  contentType: string;
128
+ issuedAt: number;
114
129
  expiresAt: number;
115
130
  }
116
131
 
@@ -138,8 +153,10 @@ export interface ICoreMailSubmission {
138
153
  transportMessageId?: string;
139
154
  attempts: number;
140
155
  nextAttemptAt?: number;
156
+ error?: ICoreMailErrorData;
141
157
  acceptedAt?: number;
142
158
  deliveredAt?: number;
159
+ terminalAt?: number;
143
160
  createdAt: number;
144
161
  updatedAt: number;
145
162
  }
@@ -158,6 +175,13 @@ export interface ICoreMailInboundDelivery {
158
175
  subject?: string;
159
176
  receivedAt: number;
160
177
  updatedAt: number;
178
+ acknowledgedAt?: number;
179
+ acknowledgedOutcome?: TCoreMailInboundAckOutcome;
180
+ }
181
+
182
+ export interface ICoreMailInboundDeliveryPage {
183
+ deliveries: ICoreMailInboundDelivery[];
184
+ nextCursor?: string;
161
185
  }
162
186
 
163
187
  export interface ICoreMailBindingCredentialVerifier {
@@ -172,7 +196,7 @@ export interface ICoreMailBindingCredentialVerifier {
172
196
  }
173
197
 
174
198
  export interface ICoreMailBindingDesiredState {
175
- schemaVersion: 1;
199
+ schemaVersion: 2;
176
200
  bindingId: string;
177
201
  serviceId: string;
178
202
  tenantId: string;
@@ -183,15 +207,17 @@ export interface ICoreMailBindingDesiredState {
183
207
  allowedSenders: string[];
184
208
  inboundRecipients: string[];
185
209
  defaultSender?: string;
186
- limits?: {
187
- messagesPerMinute?: number;
188
- messagesPerDay?: number;
189
- maxPendingInbound?: number;
210
+ limits: {
211
+ messagesPerMinute: number;
212
+ messagesPerDay: number;
213
+ maxPendingInbound: number;
190
214
  };
191
215
  }
192
216
 
193
217
  export interface ICoreMailGatewayDesiredState {
194
218
  endpointUrl: string;
219
+ /** Canonical CoreMail HTTPS origin authorized for path-only transfer grants. */
220
+ coreMailTransferOrigin: string;
195
221
  credentialId: string;
196
222
  credentialVersion: number;
197
223
  /** Key in CoreMail's resolved runtime secret material, never a plaintext value. */
@@ -220,11 +246,21 @@ export interface ICoreMailGatewayPeerDesiredState {
220
246
  credentials: ICoreMailBindingCredentialVerifier[];
221
247
  }
222
248
 
249
+ export interface ICoreMailRuntimeKeyReference {
250
+ keyId: string;
251
+ version: number;
252
+ state: TCoreMailCredentialState;
253
+ /** Key in CoreMail's resolved runtime secret material, never a plaintext value. */
254
+ secretKey: string;
255
+ acceptUntil?: number;
256
+ }
257
+
223
258
  export interface ICoreMailDesiredState {
224
- schemaVersion: 1;
259
+ schemaVersion: 2;
225
260
  configEpoch: number;
226
261
  bindings: ICoreMailBindingDesiredState[];
227
262
  gateway: ICoreMailGatewayDesiredState;
263
+ cursorKeys: ICoreMailRuntimeKeyReference[];
228
264
  }
229
265
 
230
266
  export interface ICoreMailReplicaIdentity {
@@ -239,7 +275,9 @@ export interface ICoreMailBindingReconciliationStatus {
239
275
  bindingId: string;
240
276
  revision: number;
241
277
  state: TCoreMailBindingState;
242
- activeSessionsByCredentialVersion: Array<{
278
+ pendingInboundCount: number;
279
+ activeSessionsByCredential: Array<{
280
+ credentialId: string;
243
281
  version: number;
244
282
  count: number;
245
283
  }>;
@@ -281,7 +319,10 @@ export interface ICoreMailGatewayOutboundStatus {
281
319
  >;
282
320
  attempts: number;
283
321
  nextAttemptAt?: number;
322
+ error?: ICoreMailErrorData;
284
323
  smtpCode?: number;
324
+ deliveredAt?: number;
325
+ terminalAt?: number;
285
326
  updatedAt: number;
286
327
  }
287
328
 
@@ -295,12 +336,83 @@ export const coreMailLimits = Object.freeze({
295
336
  aggregateAttachmentBytes: 17 * 1024 * 1024,
296
337
  mimeFramingBytes: 512 * 1024,
297
338
  serializedMimeBytes: 30 * 1024 * 1024,
339
+ recipientCount: 100,
340
+ inboundPageSize: 100,
341
+ inboundPageBytes: 56 * 1024,
342
+ cursorBytes: 1_024,
343
+ cursorMinimumDecodedBytes: 32,
344
+ routingHandleTtlMs: 5 * 60 * 1000,
298
345
  transferGrantTtlMs: 5 * 60 * 1000,
299
346
  transferHeaderTimeoutMs: 10 * 1000,
300
347
  transferIdleTimeoutMs: 15 * 1000,
301
348
  transferOverallTimeoutMs: 2 * 60 * 1000,
302
349
  } as const);
303
350
 
351
+ export const coreMailTransferTokenPolicy = Object.freeze({
352
+ format: 'base64url-256' as const,
353
+ decodedBytes: 32,
354
+ encodedCharacters: 43,
355
+ });
356
+
357
+ export const coreMailWorkloadOperationPolicy = Object.freeze({
358
+ active: Object.freeze({
359
+ outbound: Object.freeze([
360
+ 'coreMailPrepareOutboundSubmission',
361
+ 'coreMailPrepareOutboundPartUpload',
362
+ 'coreMailCompleteOutboundPartUpload',
363
+ 'coreMailFinalizeOutboundSubmission',
364
+ 'coreMailGetOutboundSubmission',
365
+ ] satisfies TCoreMailWorkloadOperation[]),
366
+ inbound: Object.freeze([
367
+ 'coreMailListInboundDeliveries',
368
+ 'coreMailPrepareInboundFetch',
369
+ 'coreMailCompleteInboundFetch',
370
+ 'coreMailAcknowledgeInboundDelivery',
371
+ ] satisfies TCoreMailWorkloadOperation[]),
372
+ }),
373
+ draining: Object.freeze({
374
+ outbound: Object.freeze([
375
+ 'coreMailGetOutboundSubmission',
376
+ ] satisfies TCoreMailWorkloadOperation[]),
377
+ inbound: Object.freeze([
378
+ 'coreMailListInboundDeliveries',
379
+ 'coreMailPrepareInboundFetch',
380
+ 'coreMailCompleteInboundFetch',
381
+ 'coreMailAcknowledgeInboundDelivery',
382
+ ] satisfies TCoreMailWorkloadOperation[]),
383
+ }),
384
+ disabled: Object.freeze({
385
+ outbound: Object.freeze([] satisfies TCoreMailWorkloadOperation[]),
386
+ inbound: Object.freeze([] satisfies TCoreMailWorkloadOperation[]),
387
+ }),
388
+ });
389
+
390
+ export const coreMailRetentionPolicy = Object.freeze({
391
+ terminalOutboundMs: 30 * 24 * 60 * 60 * 1000,
392
+ acknowledgedInboundMs: 30 * 24 * 60 * 60 * 1000,
393
+ idempotencyReceiptMs: 30 * 24 * 60 * 60 * 1000,
394
+ expiredCapabilityMs: 24 * 60 * 60 * 1000,
395
+ pendingInboundAgePurge: false as const,
396
+ });
397
+
398
+ export const coreMailQuotaPolicy = Object.freeze({
399
+ windows: 'fixed-utc-minute-and-day' as const,
400
+ outboundUnit: 'first-durable-idempotency-insert' as const,
401
+ outboundReplayConsumesUnit: false as const,
402
+ pendingInboundStates: ['pending', 'fetching', 'fetched'] as const,
403
+ inboundAcknowledgementReleasesUnit: true as const,
404
+ });
405
+
406
+ export const coreMailTransferProtocol = Object.freeze({
407
+ pathPrefix: '/transfers/',
408
+ authorizationScheme: 'Bearer',
409
+ putSuccessStatus: 204,
410
+ getSuccessStatus: 200,
411
+ requireContentLength: true,
412
+ requireContentType: true,
413
+ integritySource: 'grant-metadata-and-completion' as const,
414
+ });
415
+
304
416
  export const coreMailCredentialVerifierPolicy = Object.freeze({
305
417
  format: 'argon2id-v1' as const,
306
418
  version: 19,
@@ -3,7 +3,7 @@ import type { IGatewayRouteDnsResult } from './gateway.js';
3
3
  import type { IImageRolloutStatus, IImmutableImageDeploymentPlan, TSha256Digest } from './immutableimage.js';
4
4
  import { normalizeImmutableReleaseTag, normalizeSha256Digest } from './immutableimage.js';
5
5
  import type { IRegistryTarget } from './registry.js';
6
- import { normalizeServiceAbsolutePath, validateServiceContainerArgs, validateServiceSecretFiles } from './service.js';
6
+ import { normalizeServiceAbsolutePath, validateServiceContainerArgs } from './service.js';
7
7
  import type { TServiceTargetPortRef } from './serviceports.js';
8
8
 
9
9
  export type TDeploymentOperationMode = 'existing-service' | 'greenfield';
@@ -162,10 +162,12 @@ export const validateDeploymentProposedConfiguration = (
162
162
  || configurationArg.projectName.length > 200) {
163
163
  errors.push('projectName must be bounded and non-empty');
164
164
  }
165
- if (!validateStringArray(configurationArg.targetPlatforms, 8, (platformArg) => (
166
- /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(platformArg)
167
- ))) {
168
- errors.push('targetPlatforms must be unique canonical platform identifiers');
165
+ if (!validateStringArray(configurationArg.targetPlatforms, 2, (platformArg) => (
166
+ platformArg === 'linux/amd64' || platformArg === 'linux/arm64'
167
+ )) || (configurationArg.targetPlatforms as string[]).length === 0
168
+ || JSON.stringify(configurationArg.targetPlatforms)
169
+ !== JSON.stringify([...(configurationArg.targetPlatforms as string[])].sort())) {
170
+ errors.push('targetPlatforms must be a sorted non-empty supported platform set');
169
171
  }
170
172
  if (!validateStringArray(configurationArg.publicDomains, 32, (domainArg) => (
171
173
  canonicalizeDeploymentHostname(domainArg) === domainArg
@@ -177,33 +179,7 @@ export const validateDeploymentProposedConfiguration = (
177
179
  ))) {
178
180
  errors.push('environmentVariableNames must be unique canonical names');
179
181
  }
180
- if (!validateStringArray(configurationArg.secretReferenceIds, 64, (idArg) => (
181
- idArg.length <= 253 && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(idArg)
182
- ))) {
183
- errors.push('secretReferenceIds must be unique canonical identifiers');
184
- }
185
- const volumeMountPaths = Array.isArray(configurationArg.volumeMounts)
186
- ? configurationArg.volumeMounts.flatMap((mountArg) => (
187
- isRecord(mountArg) && typeof mountArg.mountPath === 'string' ? [mountArg.mountPath] : []
188
- ))
189
- : [];
190
182
  errors.push(...validateServiceContainerArgs(configurationArg.containerArgs));
191
- errors.push(...validateServiceSecretFiles(configurationArg.secretFiles, volumeMountPaths));
192
- if (Array.isArray(configurationArg.secretFiles)
193
- && Array.isArray(configurationArg.environmentVariableNames)) {
194
- const publicEnvironmentNames = new Set(
195
- configurationArg.environmentVariableNames.filter((nameArg): nameArg is string => (
196
- typeof nameArg === 'string'
197
- )),
198
- );
199
- if (configurationArg.secretFiles.some((fileArg) => (
200
- isRecord(fileArg)
201
- && typeof fileArg.sourceKey === 'string'
202
- && publicEnvironmentNames.has(fileArg.sourceKey)
203
- ))) {
204
- errors.push('secretFiles source keys must not be declared as environment variables');
205
- }
206
- }
207
183
  if (!Array.isArray(configurationArg.containerPorts)
208
184
  || configurationArg.containerPorts.length > 64
209
185
  || configurationArg.containerPorts.some((portArg) => !Number.isSafeInteger(portArg)
@@ -1,10 +1,10 @@
1
1
  import type {
2
+ TImmutableContainerPlatform,
2
3
  TOciManifestMediaType,
3
4
  TSha256Digest,
4
5
  } from './immutableimage.js';
5
6
  import { normalizeImmutableReleaseTag, normalizeSha256Digest } from './immutableimage.js';
6
7
  import type { TServiceDeploymentCapability } from './user.js';
7
- import type { IServiceSecretFile } from './service.js';
8
8
  import type {
9
9
  IDeploymentRouteRequest,
10
10
  IServiceDeploymentOperation,
@@ -145,14 +145,11 @@ export interface IDeploymentPreflightProposedConfiguration {
145
145
  schemaVersion: 1;
146
146
  declarationComplete: boolean;
147
147
  projectName: string;
148
- targetPlatforms: string[];
148
+ targetPlatforms: TImmutableContainerPlatform[];
149
149
  publicDomains: string[];
150
150
  environmentVariableNames: string[];
151
- secretReferenceIds: string[];
152
151
  /** Exact OCI/Docker argument vector appended after the image entrypoint. */
153
152
  containerArgs?: string[];
154
- /** Secret-bundle keys that must be delivered only as Docker secret files. */
155
- secretFiles?: IServiceSecretFile[];
156
153
  containerPorts: number[];
157
154
  volumeMounts: IDeploymentPreflightProposedVolumeMount[];
158
155
  requiredCapabilities: Array<'database' | 'objectstorage' | 'pushnotification'>;
@@ -2,6 +2,58 @@ export type THostedAppHostType = 'onebox' | 'cloudly' | string;
2
2
 
3
3
  export const hostedAppOidcRoleClaim = 'roles' as const;
4
4
  export const hostedAppOidcAppInstanceIdClaim = 'servezone_app_instance_id' as const;
5
+ export const hostedAppMachineServiceIdClaim = 'servezone_service_id' as const;
6
+ export const hostedAppMachineAudience = 'serve.zone/hosted-app-lifecycle' as const;
7
+
8
+ export interface IHostedAppMachineClaims {
9
+ sub: string;
10
+ aud: typeof hostedAppMachineAudience;
11
+ iat: number;
12
+ exp: number;
13
+ [hostedAppOidcAppInstanceIdClaim]: string;
14
+ [hostedAppMachineServiceIdClaim]: string;
15
+ }
16
+
17
+ export const createHostedAppMachineSubject = (appInstanceIdArg: string): string => (
18
+ `urn:serve.zone:hosted-app:${appInstanceIdArg}`
19
+ );
20
+
21
+ export const validateHostedAppMachineClaims = (
22
+ claimsArg: unknown,
23
+ expectedArg: {
24
+ appInstanceId: string;
25
+ serviceId: string;
26
+ nowEpochSeconds?: number;
27
+ },
28
+ ): string[] => {
29
+ if (!claimsArg || typeof claimsArg !== 'object' || Array.isArray(claimsArg)) {
30
+ return ['hosted app machine claims must be an object'];
31
+ }
32
+ const claims = claimsArg as Record<string, unknown>;
33
+ const errors: string[] = [];
34
+ if (claims.sub !== createHostedAppMachineSubject(expectedArg.appInstanceId)) {
35
+ errors.push('hosted app machine subject does not match');
36
+ }
37
+ if (claims.aud !== hostedAppMachineAudience) {
38
+ errors.push('hosted app machine audience does not match');
39
+ }
40
+ if (claims[hostedAppOidcAppInstanceIdClaim] !== expectedArg.appInstanceId) {
41
+ errors.push('hosted app machine app instance does not match');
42
+ }
43
+ if (claims[hostedAppMachineServiceIdClaim] !== expectedArg.serviceId) {
44
+ errors.push('hosted app machine service does not match');
45
+ }
46
+ const now = expectedArg.nowEpochSeconds ?? Math.floor(Date.now() / 1000);
47
+ if (!Number.isSafeInteger(claims.iat) || (claims.iat as number) < 0
48
+ || (claims.iat as number) > now) {
49
+ errors.push('hosted app machine issued-at time is malformed');
50
+ }
51
+ if (!Number.isSafeInteger(claims.exp) || (claims.exp as number) <= now
52
+ || (Number.isSafeInteger(claims.iat) && (claims.exp as number) <= (claims.iat as number))) {
53
+ errors.push('hosted app machine expiry is malformed or expired');
54
+ }
55
+ return errors;
56
+ };
5
57
 
6
58
  export interface IHostedAppRoleDefinition {
7
59
  /** Stable role identifier emitted in the hosted app's audience-scoped token. */
@@ -276,7 +328,7 @@ export type THostedAppRuntimeStatus =
276
328
  | 'stopped'
277
329
  | 'unknown';
278
330
 
279
- export type THostedAppBootstrapActionType = 'setupLink' | 'credentials' | 'message';
331
+ export type THostedAppBootstrapActionType = 'setupRoute' | 'message';
280
332
 
281
333
  export type THostedAppBootstrapActionStatus =
282
334
  | 'requested'
@@ -293,21 +345,12 @@ export type THostedAppUpgradeStatus =
293
345
  | 'success'
294
346
  | 'failed';
295
347
 
296
- export interface IHostedAppRuntimeIdentity {
297
- appInstanceId: string;
298
- appControlToken: string;
299
- hostType?: THostedAppHostType;
300
- }
301
-
302
- export interface IHostedAppBootstrapAction {
348
+ interface IHostedAppBootstrapActionBase {
303
349
  id: string;
304
- type: THostedAppBootstrapActionType;
350
+ /** Server-managed CAS revision incremented on every action transition. */
351
+ revision: number;
305
352
  status: THostedAppBootstrapActionStatus;
306
353
  label: string;
307
- url?: string;
308
- username?: string;
309
- password?: string;
310
- message?: string;
311
354
  expiresAt?: number;
312
355
  createdAt: number;
313
356
  updatedAt: number;
@@ -315,6 +358,18 @@ export interface IHostedAppBootstrapAction {
315
358
  dismissedAt?: number;
316
359
  }
317
360
 
361
+ export type IHostedAppBootstrapAction =
362
+ | (IHostedAppBootstrapActionBase & {
363
+ type: 'setupRoute';
364
+ route: string;
365
+ message?: never;
366
+ })
367
+ | (IHostedAppBootstrapActionBase & {
368
+ type: 'message';
369
+ route?: never;
370
+ message: string;
371
+ });
372
+
318
373
  export interface IHostedAppUpgradeState {
319
374
  status: THostedAppUpgradeStatus;
320
375
  appTemplateId?: string;
@@ -347,9 +402,80 @@ export interface IHostedAppLifecycleState extends IHostedAppLifecycleReport {
347
402
  upgradeState?: IHostedAppUpgradeState;
348
403
  }
349
404
 
350
- export type IHostedAppBootstrapActionRequest = Omit<
351
- IHostedAppBootstrapAction,
352
- 'id' | 'status' | 'createdAt' | 'updatedAt' | 'completedAt' | 'dismissedAt'
353
- > & {
354
- id?: string;
405
+ type THostedAppBootstrapActionServerFields =
406
+ | 'id'
407
+ | 'revision'
408
+ | 'status'
409
+ | 'createdAt'
410
+ | 'updatedAt'
411
+ | 'completedAt'
412
+ | 'dismissedAt';
413
+
414
+ export type IHostedAppBootstrapActionRequest =
415
+ | (Omit<
416
+ Extract<IHostedAppBootstrapAction, { type: 'setupRoute' }>,
417
+ THostedAppBootstrapActionServerFields
418
+ > & { id?: string })
419
+ | (Omit<
420
+ Extract<IHostedAppBootstrapAction, { type: 'message' }>,
421
+ THostedAppBootstrapActionServerFields
422
+ > & { id?: string });
423
+
424
+ export const normalizeHostedAppSetupRoute = (routeArg: unknown): string | undefined => {
425
+ if (typeof routeArg !== 'string'
426
+ || !routeArg.startsWith('/')
427
+ || routeArg.startsWith('//')
428
+ || /[\\?#\s]/.test(routeArg)
429
+ || /%5c/i.test(routeArg)) {
430
+ return undefined;
431
+ }
432
+ const parsed = new URL(routeArg, 'https://app.invalid');
433
+ return parsed.pathname === routeArg ? routeArg : undefined;
434
+ };
435
+
436
+ export const validateHostedAppBootstrapActionRequest = (
437
+ actionArg: unknown,
438
+ ): string[] => {
439
+ if (!actionArg || typeof actionArg !== 'object' || Array.isArray(actionArg)) {
440
+ return ['hosted app bootstrap action must be an object'];
441
+ }
442
+ const action = actionArg as Record<string, unknown>;
443
+ const errors: string[] = [];
444
+ const allowedKeys = action.type === 'setupRoute'
445
+ ? new Set(['id', 'type', 'label', 'route', 'expiresAt'])
446
+ : action.type === 'message'
447
+ ? new Set(['id', 'type', 'label', 'message', 'expiresAt'])
448
+ : new Set(['id', 'type', 'label', 'expiresAt']);
449
+ if (Object.keys(action).some((keyArg) => !allowedKeys.has(keyArg))) {
450
+ errors.push('hosted app bootstrap action contains fields outside its schema');
451
+ }
452
+ if (action.id !== undefined
453
+ && (typeof action.id !== 'string'
454
+ || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(action.id))) {
455
+ errors.push('hosted app bootstrap action id must be canonical');
456
+ }
457
+ if (action.expiresAt !== undefined
458
+ && (!Number.isSafeInteger(action.expiresAt) || (action.expiresAt as number) < 1)) {
459
+ errors.push('hosted app bootstrap action expiresAt must be a positive integer');
460
+ }
461
+ if (action.type !== 'setupRoute' && action.type !== 'message') {
462
+ errors.push('hosted app bootstrap action type is unsupported');
463
+ }
464
+ if (typeof action.label !== 'string' || !action.label.trim() || action.label.length > 200) {
465
+ errors.push('hosted app bootstrap action label must be bounded');
466
+ }
467
+ if (action.type === 'setupRoute') {
468
+ if (normalizeHostedAppSetupRoute(action.route) !== action.route) {
469
+ errors.push('hosted app setup route must be a canonical same-origin absolute path');
470
+ }
471
+ if ('message' in action) errors.push('hosted app setup route forbids message');
472
+ }
473
+ if (action.type === 'message') {
474
+ if (typeof action.message !== 'string' || !action.message.trim()
475
+ || action.message.length > 4000) {
476
+ errors.push('hosted app bootstrap message must be bounded');
477
+ }
478
+ if ('route' in action) errors.push('hosted app message forbids route');
479
+ }
480
+ return errors;
355
481
  };