@oondemand/oon-core-back 0.6.8 → 0.6.10

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/bin/delivery.js CHANGED
@@ -5,6 +5,7 @@ const fs = require("node:fs");
5
5
  const path = require("node:path");
6
6
  const { getContextWithPhase, setState, addEvidence } = require("../src/delivery/api");
7
7
  const { provisionRuntime, deployRuntime, waitHttp, activateMember } = require("../src/delivery/runtime");
8
+ const { canonicalizeDeliveryContext } = require("../src/delivery/technicalIdentity");
8
9
 
9
10
  function flag(name, fallback) {
10
11
  const args = process.argv.slice(3);
@@ -16,6 +17,7 @@ function flag(name, fallback) {
16
17
  }
17
18
  function required(value, name) { if (!value) throw new Error(`${name} obrigatório.`); return value; }
18
19
  function secret() { return required(process.env.OON_DELIVERY_SHARED_SECRET, "OON_DELIVERY_SHARED_SECRET"); }
20
+ function normalizedContext(value) { return { ...canonicalizeDeliveryContext(value) }; }
19
21
 
20
22
  async function main() {
21
23
  const command = process.argv[2];
@@ -25,7 +27,8 @@ async function main() {
25
27
  if (command === "context") {
26
28
  const phase = flag("--phase", "build");
27
29
  const output = required(flag("--output"), "--output");
28
- const context = await getContextWithPhase({ apiUrl: required(apiUrl, "--api"), releaseId: required(releaseId, "--release"), token: secret(), phase });
30
+ const fetched = await getContextWithPhase({ apiUrl: required(apiUrl, "--api"), releaseId: required(releaseId, "--release"), token: secret(), phase });
31
+ const context = normalizedContext(fetched);
29
32
  fs.writeFileSync(path.resolve(output), JSON.stringify(context, null, 2), { mode: 0o600 });
30
33
  return;
31
34
  }
@@ -40,7 +43,7 @@ async function main() {
40
43
  if (command === "deploy") {
41
44
  const contextFile = required(flag("--context"), "--context");
42
45
  const imageDigest = required(flag("--image-digest"), "--image-digest");
43
- const context = JSON.parse(fs.readFileSync(path.resolve(contextFile), "utf8"));
46
+ const context = normalizedContext(JSON.parse(fs.readFileSync(path.resolve(contextFile), "utf8")));
44
47
  let failureState = "falha_infra";
45
48
  try {
46
49
  await setState({ apiUrl, releaseId, token: secret(), etapa: "provisionando", imageDigest });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -113,6 +113,10 @@ function technicalIdentity({ appCode, instanceUid, environment }) {
113
113
  const mongoUsername = `${base}-rw`;
114
114
  const serviceAccount = `${base}-runtime`;
115
115
  const secret = `${base}-runtime`;
116
+ const provisionJob = `${base}-provision`;
117
+ const frontendDnsLabel = base;
118
+ const backendDnsLabel = `${base}-api`;
119
+ const tlsSecret = `${base}-tls`;
116
120
 
117
121
  const lengths = {
118
122
  base: base.length,
@@ -120,6 +124,10 @@ function technicalIdentity({ appCode, instanceUid, environment }) {
120
124
  mongoUsername: mongoUsername.length,
121
125
  serviceAccount: serviceAccount.length,
122
126
  secret: secret.length,
127
+ provisionJob: provisionJob.length,
128
+ frontendDnsLabel: frontendDnsLabel.length,
129
+ backendDnsLabel: backendDnsLabel.length,
130
+ tlsSecret: tlsSecret.length,
123
131
  };
124
132
  const invalidLength = Object.entries(lengths).find(([, length]) => length > 63);
125
133
  if (invalidLength) {
@@ -141,6 +149,10 @@ function technicalIdentity({ appCode, instanceUid, environment }) {
141
149
  mongoUsername,
142
150
  serviceAccount,
143
151
  secret,
152
+ provisionJob,
153
+ frontendDnsLabel,
154
+ backendDnsLabel,
155
+ tlsSecret,
144
156
  });
145
157
  }
146
158
 
@@ -159,4 +171,4 @@ module.exports = Object.freeze({
159
171
  encodeInstanceUid,
160
172
  generateInstanceUid,
161
173
  technicalIdentity,
162
- });
174
+ });
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+
3
+ const { assertMongoIdentity, recoveryRequired } = require("./mongoProvisioning");
4
+
5
+ // The store must create the intent exclusively, then replace it by resourceVersion.
6
+ // A present record without a credential is an interrupted operation, never absence.
7
+ async function resolveMongoCredential({ identity, read, begin, persist, provision, validate }) {
8
+ const record = await read();
9
+ if (record !== null) {
10
+ if (!record.mongoUri) throw recoveryRequired("MONGO_CREDENTIAL_MISSING");
11
+ assertMongoIdentity(record.mongoUri, identity);
12
+ try { await validate(record.mongoUri); }
13
+ catch { throw recoveryRequired("MONGO_PERSISTED_CREDENTIAL_INVALID"); }
14
+ return { mongoUri: record.mongoUri, created: false };
15
+ }
16
+
17
+ await begin();
18
+ let captured;
19
+ try {
20
+ const result = await provision(async (uri) => {
21
+ assertMongoIdentity(uri, identity);
22
+ await persist(uri);
23
+ captured = uri;
24
+ });
25
+ if (!captured || result.mongoUri !== captured) throw recoveryRequired();
26
+ return { mongoUri: captured, created: true };
27
+ } catch {
28
+ // Do not serialize driver/provider errors, URIs, tokens or creation responses.
29
+ throw recoveryRequired("MONGO_PROVISIONING_INCOMPLETE");
30
+ }
31
+ }
32
+
33
+ module.exports = { resolveMongoCredential };
@@ -1,11 +1,11 @@
1
1
  "use strict";
2
2
 
3
- const { mongo } = require("mongoose");
3
+ const defaultMongoClient = () => require("mongoose").mongo.MongoClient;
4
+ const { technicalIdentity } = require("../core/technicalIdentity");
4
5
 
5
6
  const DIGITALOCEAN_API = "https://api.digitalocean.com/v2";
6
7
  const DIGITALOCEAN_HOST_SUFFIX = ".mongo.ondigitalocean.com";
7
8
  const DIGITALOCEAN_RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
8
- const DIGITALOCEAN_SERVER_STATUS = new Set([500, 502, 503, 504]);
9
9
 
10
10
  function delay(ms) {
11
11
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -24,53 +24,76 @@ function dnsLabel(value, max = 63) {
24
24
  .replace(/-+$/, "");
25
25
  }
26
26
 
27
- function mongoIdentity(appCode, environment) {
27
+ function mongoIdentity(appCode, environment, { instanceUid } = {}) {
28
+ if (instanceUid != null) {
29
+ const identity = technicalIdentity({ appCode, instanceUid, environment });
30
+ return {
31
+ mode: "canonical",
32
+ identityVersion: identity.version,
33
+ instanceUid: identity.instanceUid,
34
+ dbName: identity.database,
35
+ username: identity.mongoUsername,
36
+ };
37
+ }
28
38
  return {
39
+ mode: "legacy",
29
40
  dbName: dnsLabel(`${appCode}_${environment}`, 48).replace(/-/g, "_"),
30
41
  username: dnsLabel(`${appCode}-${environment}`, 48),
31
42
  };
32
43
  }
33
44
 
34
- function mongoHost(uri) {
45
+ function recoveryRequired(code = "MONGO_RECOVERY_REQUIRED") {
46
+ const error = new Error(`${code}: recuperação explícita necessária; credenciais preservadas.`);
47
+ error.code = code;
48
+ return error;
49
+ }
50
+
51
+ // WHATWG URL rejects MongoDB seed lists with multiple host:port pairs.
52
+ function parseMongoUri(uri) {
53
+ const match = typeof uri === "string" && uri.match(/^(mongodb(?:\+srv)?):\/\/([^/?#]+)(?:\/([^?#]*))?(?:\?([^#]*))?$/);
54
+ if (!match) throw recoveryRequired("MONGO_URI_INVALID");
55
+ const [, scheme, authority, database = "", query = ""] = match;
56
+ const at = authority.lastIndexOf("@");
57
+ const credentials = at < 0 ? "" : authority.slice(0, at);
58
+ const hosts = authority.slice(at + 1);
59
+ if (!hosts) throw recoveryRequired("MONGO_URI_INVALID");
35
60
  try {
36
- return new URL(uri).hostname.toLowerCase();
37
- } catch {
38
- throw new Error("MONGO_ADMIN_URI inválida.");
39
- }
61
+ return { scheme, hosts, database: decodeURIComponent(database),
62
+ username: decodeURIComponent(credentials.split(":")[0]), query: new URLSearchParams(query) };
63
+ } catch { throw recoveryRequired("MONGO_URI_INVALID"); }
64
+ }
65
+
66
+ function mongoHost(uri) {
67
+ const parsed = parseMongoUri(uri);
68
+ return parsed.hosts.split(",")[0].replace(/:\d+$/, "").toLowerCase();
40
69
  }
41
70
 
42
71
  function resolveMongoProvisioner({ adminUri, provisioner = "auto" }) {
43
72
  const normalized = String(provisioner || "auto").trim().toLowerCase();
44
73
  if (!["auto", "native", "digitalocean"].includes(normalized)) {
45
- throw new Error(
46
- `MONGO_PROVISIONER inválido: ${normalized}. Use auto, native ou digitalocean.`,
47
- );
74
+ throw recoveryRequired("MONGO_PROVISIONER_INVALID");
48
75
  }
49
76
  if (normalized !== "auto") return normalized;
50
- return mongoHost(adminUri).endsWith(DIGITALOCEAN_HOST_SUFFIX)
51
- ? "digitalocean"
52
- : "native";
77
+ return mongoHost(adminUri).endsWith(DIGITALOCEAN_HOST_SUFFIX) ? "digitalocean" : "native";
53
78
  }
54
79
 
55
- function buildRuntimeMongoUri({
56
- adminUri,
57
- username,
58
- password,
59
- dbName,
60
- authSource,
61
- }) {
62
- const uri = new URL(adminUri);
63
- uri.username = username;
64
- uri.password = password;
65
- uri.pathname = `/${dbName}`;
66
- uri.searchParams.set("authSource", authSource);
67
- return uri.toString();
80
+ function buildRuntimeMongoUri({ adminUri, username, password, dbName, authSource }) {
81
+ const parsed = parseMongoUri(adminUri);
82
+ parsed.query.set("authSource", authSource);
83
+ return `${parsed.scheme}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${parsed.hosts}/${encodeURIComponent(dbName)}?${parsed.query}`;
84
+ }
85
+
86
+ function assertMongoIdentity(uri, identity) {
87
+ const parsed = parseMongoUri(uri);
88
+ if (parsed.username !== identity.username || parsed.database !== identity.dbName) {
89
+ throw recoveryRequired("MONGO_IDENTITY_MISMATCH");
90
+ }
68
91
  }
69
92
 
70
93
  async function validateMongoUri(
71
94
  mongoUri,
72
95
  {
73
- MongoClient = mongo.MongoClient,
96
+ MongoClient = defaultMongoClient(),
74
97
  timeoutMs = 15000,
75
98
  writeProbe = true,
76
99
  } = {},
@@ -81,9 +104,16 @@ async function validateMongoUri(
81
104
  maxPoolSize: 2,
82
105
  });
83
106
 
84
- await client.connect();
85
107
  try {
108
+ await client.connect();
86
109
  const db = client.db();
110
+ const target = parseMongoUri(mongoUri);
111
+ const status = await db.command({ connectionStatus: 1 });
112
+ const roles = status?.authInfo?.authenticatedUserRoles;
113
+ if (!target.database || !Array.isArray(roles) || !roles.length ||
114
+ roles.some(role => role.role !== "readWrite" || role.db !== target.database)) {
115
+ throw recoveryRequired("MONGO_RUNTIME_SCOPE_UNSAFE");
116
+ }
87
117
  await db.command({ ping: 1 });
88
118
  if (writeProbe) {
89
119
  const probe = db.collection("__oon_runtime_probe");
@@ -121,7 +151,7 @@ async function waitForMongoUri(
121
151
  }
122
152
  }
123
153
  }
124
- const reason = lastError?.codeName || lastError?.code || lastError?.name;
154
+ const reason = [13, 18].includes(lastError?.code) ? lastError.code : undefined;
125
155
  throw new Error(
126
156
  `A credencial derivada do MongoDB não passou no teste de leitura e escrita${
127
157
  reason ? ` (${reason})` : ""
@@ -129,15 +159,23 @@ async function waitForMongoUri(
129
159
  );
130
160
  }
131
161
 
132
- async function provisionNativeMongo({ adminUri, appCode, environment }) {
133
- const { dbName, username } = mongoIdentity(appCode, environment);
162
+ async function provisionNativeMongo({ adminUri, appCode, environment, instanceUid }, deps = {}) {
163
+ const identity = mongoIdentity(appCode, environment, { instanceUid });
164
+ const { dbName, username } = identity;
134
165
  const password = randomSecret(30);
135
- const client = new mongo.MongoClient(adminUri, {
136
- serverSelectionTimeoutMS: 15000,
166
+ const MongoClient = deps.MongoClient || defaultMongoClient();
167
+ const client = new MongoClient(adminUri, {
168
+ serverSelectionTimeoutMS: 15000, connectTimeoutMS: 15000, maxPoolSize: 2,
137
169
  });
138
- await client.connect();
139
170
  try {
171
+ await client.connect();
140
172
  const db = client.db(dbName);
173
+ const existing = await db.command({ usersInfo: username });
174
+ if (!Array.isArray(existing.users) || existing.users.length) throw recoveryRequired();
175
+ const mongoUri = buildRuntimeMongoUri({ adminUri, username, password, dbName, authSource: dbName });
176
+ if (typeof deps.persistCredential !== "function") throw recoveryRequired("MONGO_CREDENTIAL_PERSISTENCE_REQUIRED");
177
+ // Capture before createUser: loss of its response never requires password rotation.
178
+ await deps.persistCredential(mongoUri);
141
179
  try {
142
180
  await db.command({
143
181
  createUser: username,
@@ -150,11 +188,7 @@ async function provisionNativeMongo({ adminUri, appCode, environment }) {
150
188
  error.code === 51003 ||
151
189
  /already exists/i.test(error.message);
152
190
  if (!alreadyExists) throw error;
153
- await db.command({
154
- updateUser: username,
155
- pwd: password,
156
- roles: [{ role: "readWrite", db: dbName }],
157
- });
191
+ throw recoveryRequired();
158
192
  }
159
193
  } finally {
160
194
  await client.close();
@@ -163,6 +197,9 @@ async function provisionNativeMongo({ adminUri, appCode, environment }) {
163
197
  return {
164
198
  dbName,
165
199
  username,
200
+ ...(identity.mode === "canonical"
201
+ ? { instanceUid: identity.instanceUid, technicalIdentityVersion: identity.identityVersion }
202
+ : {}),
166
203
  mongoUri: buildRuntimeMongoUri({
167
204
  adminUri,
168
205
  username,
@@ -191,7 +228,7 @@ async function digitalOceanRequest(
191
228
  } = {},
192
229
  ) {
193
230
  if (!fetchImpl) throw new Error("fetch não disponível para a DigitalOcean API.");
194
- const attempts = Math.max(1, Number(retryAttempts) || 3);
231
+ const attempts = method === "GET" ? Math.max(1, Number(retryAttempts) || 3) : 1;
195
232
 
196
233
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
197
234
  const controller = new AbortController();
@@ -209,6 +246,7 @@ async function digitalOceanRequest(
209
246
  },
210
247
  body: body ? JSON.stringify(body) : undefined,
211
248
  signal: controller.signal,
249
+ redirect: "error",
212
250
  },
213
251
  );
214
252
  } catch (error) {
@@ -219,7 +257,7 @@ async function digitalOceanRequest(
219
257
  if (error.name === "AbortError") {
220
258
  throw new Error("Timeout ao acessar a DigitalOcean API.");
221
259
  }
222
- throw new Error(`Falha ao acessar a DigitalOcean API: ${error.message}`);
260
+ throw recoveryRequired("MONGO_PROVIDER_REQUEST_FAILED");
223
261
  } finally {
224
262
  clearTimeout(timeout);
225
263
  }
@@ -241,20 +279,8 @@ async function digitalOceanRequest(
241
279
  continue;
242
280
  }
243
281
 
244
- const providerMessage = String(payload.message || payload.id || "erro")
245
- .replace(/[\r\n]+/g, " ")
246
- .slice(0, 300);
247
- const providerRequestId = String(payload.request_id || "")
248
- .replace(/[^A-Za-z0-9_.:-]/g, "")
249
- .slice(0, 120);
250
- const error = new Error(
251
- `DigitalOcean API rejeitou ${method} ${path}: HTTP ${response.status} (${providerMessage})`
252
- + (providerRequestId ? ` [requestId: ${providerRequestId}]` : "")
253
- + ".",
254
- );
282
+ const error = recoveryRequired(`MONGO_PROVIDER_HTTP_${response.status}`);
255
283
  error.status = response.status;
256
- error.payload = payload;
257
- error.providerRequestId = providerRequestId || undefined;
258
284
  throw error;
259
285
  }
260
286
 
@@ -273,19 +299,14 @@ async function ensureDigitalOceanDatabase(options, deps) {
273
299
  } catch (error) {
274
300
  if (error.status === 404) {
275
301
  exists = false;
276
- } else if (DIGITALOCEAN_SERVER_STATUS.has(error.status)) {
277
- const listed = await digitalOceanRequest(
278
- { ...options, path: "/dbs", expected: [200] },
279
- deps,
280
- );
281
- exists = Array.isArray(listed.payload.dbs)
282
- && listed.payload.dbs.some((item) => item?.name === options.dbName);
283
302
  } else {
284
303
  throw error;
285
304
  }
286
305
  }
287
306
  if (exists) return;
288
307
 
308
+ // Refuse a pre-existing/ambiguous user before creating any database resource.
309
+ await assertDigitalOceanUserAbsent(options, deps);
289
310
  await digitalOceanRequest(
290
311
  {
291
312
  ...options,
@@ -307,70 +328,35 @@ function digitalOceanUserSettings(dbName) {
307
328
  };
308
329
  }
309
330
 
310
- async function provisionDigitalOceanUser(options, deps) {
311
- const userPath = `/users/${encodeURIComponent(options.username)}`;
312
- let exists = false;
331
+ async function assertDigitalOceanUserAbsent(options, deps) {
313
332
  try {
314
333
  await digitalOceanRequest(
315
- { ...options, path: userPath, expected: [200] },
334
+ { ...options, path: `/users/${encodeURIComponent(options.username)}`, expected: [200] },
316
335
  deps,
317
336
  );
318
- exists = true;
319
337
  } catch (error) {
320
- if (error.status === 404) {
321
- exists = false;
322
- } else if (DIGITALOCEAN_SERVER_STATUS.has(error.status)) {
323
- const listed = await digitalOceanRequest(
324
- { ...options, path: "/users", expected: [200] },
325
- deps,
326
- );
327
- exists = Array.isArray(listed.payload.users)
328
- && listed.payload.users.some(
329
- (item) => item?.name === options.username,
330
- );
331
- } else {
332
- throw error;
333
- }
338
+ if (error.status === 404) return;
339
+ throw error;
334
340
  }
341
+ throw recoveryRequired();
342
+ }
335
343
 
336
- const settings = digitalOceanUserSettings(options.dbName);
337
- let result;
338
- if (!exists) {
339
- result = await digitalOceanRequest(
340
- {
341
- ...options,
342
- path: "/users",
343
- method: "POST",
344
- body: { name: options.username, settings },
345
- expected: [201, 202],
346
- },
347
- deps,
348
- );
349
- } else {
350
- await digitalOceanRequest(
351
- {
352
- ...options,
353
- path: userPath,
354
- method: "PUT",
355
- body: { settings },
356
- expected: [200, 202],
357
- },
358
- deps,
359
- );
360
- result = await digitalOceanRequest(
361
- {
362
- ...options,
363
- path: `${userPath}/reset_auth`,
364
- method: "POST",
365
- body: {},
366
- expected: [200, 201, 202],
367
- },
368
- deps,
369
- );
370
- }
344
+ async function provisionDigitalOceanUser(options, deps) {
345
+ // Recheck after database creation: another publisher may have created the user.
346
+ await assertDigitalOceanUserAbsent(options, deps);
347
+ const result = await digitalOceanRequest(
348
+ {
349
+ ...options,
350
+ path: "/users",
351
+ method: "POST",
352
+ body: { name: options.username, settings: digitalOceanUserSettings(options.dbName) },
353
+ expected: [201, 202],
354
+ },
355
+ deps,
356
+ );
371
357
 
372
358
  const user = result.payload.user;
373
- if (!user?.password) {
359
+ if (user?.name !== options.username || typeof user?.password !== "string" || !user.password) {
374
360
  throw new Error(
375
361
  "DigitalOcean API não retornou a senha do usuário. O token precisa do escopo database:view_credentials.",
376
362
  );
@@ -383,6 +369,7 @@ async function provisionDigitalOceanMongo(
383
369
  adminUri,
384
370
  appCode,
385
371
  environment,
372
+ instanceUid,
386
373
  digitalOceanToken,
387
374
  digitalOceanClusterId,
388
375
  },
@@ -399,23 +386,28 @@ async function provisionDigitalOceanMongo(
399
386
  );
400
387
  }
401
388
 
402
- const { dbName, username } = mongoIdentity(appCode, environment);
389
+ const identity = mongoIdentity(appCode, environment, { instanceUid });
390
+ const { dbName, username } = identity;
403
391
  const apiOptions = {
404
392
  token: digitalOceanToken,
405
393
  clusterId: digitalOceanClusterId,
406
394
  dbName,
407
395
  username,
408
396
  };
397
+ if (typeof deps.persistCredential !== "function") throw recoveryRequired("MONGO_CREDENTIAL_PERSISTENCE_REQUIRED");
398
+ // Validate URI construction before any provider mutation.
399
+ buildRuntimeMongoUri({ adminUri, username, password: "preflight", dbName, authSource: "admin" });
409
400
  await ensureDigitalOceanDatabase(apiOptions, deps);
410
401
  const user = await provisionDigitalOceanUser(apiOptions, deps);
411
402
  const mongoUri = buildRuntimeMongoUri({
412
403
  adminUri,
413
- username: user.name || username,
404
+ username,
414
405
  password: user.password,
415
406
  dbName,
416
407
  authSource: "admin",
417
408
  });
418
409
 
410
+ await deps.persistCredential(mongoUri);
419
411
  await waitForMongoUri(mongoUri, {
420
412
  validate: deps.validateMongoUri || validateMongoUri,
421
413
  attempts: deps.validationAttempts,
@@ -424,7 +416,10 @@ async function provisionDigitalOceanMongo(
424
416
 
425
417
  return {
426
418
  dbName,
427
- username: user.name || username,
419
+ username,
420
+ ...(identity.mode === "canonical"
421
+ ? { instanceUid: identity.instanceUid, technicalIdentityVersion: identity.identityVersion }
422
+ : {}),
428
423
  mongoUri,
429
424
  provider: "digitalocean",
430
425
  };
@@ -435,7 +430,7 @@ async function provisionMongo(options, deps = {}) {
435
430
  if (provider === "digitalocean") {
436
431
  return provisionDigitalOceanMongo(options, deps);
437
432
  }
438
- const result = await provisionNativeMongo(options);
433
+ const result = await provisionNativeMongo(options, deps);
439
434
  await waitForMongoUri(result.mongoUri, {
440
435
  validate: deps.validateMongoUri || validateMongoUri,
441
436
  attempts: deps.validationAttempts,
@@ -445,10 +440,10 @@ async function provisionMongo(options, deps = {}) {
445
440
  }
446
441
 
447
442
  module.exports = {
443
+ recoveryRequired, parseMongoUri, assertMongoIdentity,
448
444
  DIGITALOCEAN_API,
449
445
  DIGITALOCEAN_HOST_SUFFIX,
450
446
  DIGITALOCEAN_RETRYABLE_STATUS,
451
- DIGITALOCEAN_SERVER_STATUS,
452
447
  mongoIdentity,
453
448
  mongoHost,
454
449
  resolveMongoProvisioner,
@@ -4,7 +4,9 @@ const crypto = require("node:crypto");
4
4
  const { spawnSync } = require("node:child_process");
5
5
  const { kubectl } = require("./command");
6
6
  const { validateInfraFromCluster } = require("../deploy/contracts/infra-contract");
7
- const { provisionMongo, validateMongoUri } = require("./mongoProvisioning");
7
+ const { resolveMongoCredential } = require("./mongoCredentialLifecycle");
8
+ const { provisionMongo, validateMongoUri, mongoIdentity, recoveryRequired } = require("./mongoProvisioning");
9
+ const { canonicalizeDeliveryContext } = require("./technicalIdentity");
8
10
 
9
11
  const RESOURCE_PROFILES = {
10
12
  nano: { cpuRequest: "50m", cpuLimit: "250m", memoryRequest: "96Mi", memoryLimit: "256Mi" },
@@ -31,29 +33,45 @@ function applyYaml(yaml) {
31
33
  }
32
34
  function readSecret(namespace, name, key) {
33
35
  try {
34
- const value = kubectl(["-n", namespace, "get", "secret", name, "-o", `jsonpath={.data.${key}}`], { capture: true });
36
+ const value = kubectl(["-n", namespace, "get", "secret", name, "--ignore-not-found", "-o", `jsonpath={.data.${key}}`], { capture: true });
35
37
  return value ? Buffer.from(value, "base64").toString("utf8") : null;
36
- } catch { return null; }
38
+ } catch { throw recoveryRequired("MONGO_SECRET_READ_FAILED"); }
39
+ }
40
+
41
+ function canonicalRuntimeContext(context) {
42
+ return canonicalizeDeliveryContext(context);
43
+ }
44
+
45
+ function identityLabels(identity) {
46
+ if (identity.mode !== "canonical") return "";
47
+ return `\n oon.runtime/instance-uid: ${identity.instanceUid}\n oon.runtime/identity-version: \"${identity.version}\"`;
48
+ }
49
+
50
+ function podIdentityLabels(identity) {
51
+ if (identity.mode !== "canonical") return "";
52
+ return `\n oon.runtime/instance-uid: ${identity.instanceUid}\n oon.runtime/identity-version: \"${identity.version}\"`;
37
53
  }
38
54
 
39
55
  function baselineManifest({ context }) {
56
+ const normalized = canonicalRuntimeContext(context);
57
+ const identity = normalized.deliveryTechnicalIdentity;
40
58
  return `apiVersion: v1
41
59
  kind: Namespace
42
60
  metadata:
43
- name: ${context.namespace}
61
+ name: ${normalized.namespace}
44
62
  labels:
45
- oon.dev/app-code: ${context.appCode}
46
- oon.dev/environment: ${context.environment}
47
- app.kubernetes.io/managed-by: oon-platform
63
+ oon.dev/app-code: ${normalized.appCode}
64
+ oon.dev/environment: ${normalized.environment}
65
+ app.kubernetes.io/managed-by: oon-platform${identityLabels(identity)}
48
66
  ---
49
67
  apiVersion: v1
50
68
  kind: ServiceAccount
51
- metadata: { name: oon-runtime, namespace: ${context.namespace} }
69
+ metadata: { name: ${identity.serviceAccount}, namespace: ${normalized.namespace} }
52
70
  automountServiceAccountToken: false
53
71
  ---
54
72
  apiVersion: v1
55
73
  kind: LimitRange
56
- metadata: { name: oon-runtime-defaults, namespace: ${context.namespace} }
74
+ metadata: { name: oon-runtime-defaults, namespace: ${normalized.namespace} }
57
75
  spec:
58
76
  limits:
59
77
  - type: Container
@@ -62,13 +80,13 @@ spec:
62
80
  ---
63
81
  apiVersion: v1
64
82
  kind: ResourceQuota
65
- metadata: { name: oon-runtime-quota, namespace: ${context.namespace} }
83
+ metadata: { name: oon-runtime-quota, namespace: ${normalized.namespace} }
66
84
  spec:
67
85
  hard: { requests.cpu: "2", requests.memory: 2Gi, limits.cpu: "4", limits.memory: 4Gi, pods: "20", services: "10" }
68
86
  ---
69
87
  apiVersion: networking.k8s.io/v1
70
88
  kind: NetworkPolicy
71
- metadata: { name: oon-runtime-baseline, namespace: ${context.namespace} }
89
+ metadata: { name: oon-runtime-baseline, namespace: ${normalized.namespace} }
72
90
  spec:
73
91
  podSelector: {}
74
92
  policyTypes: [Ingress, Egress]
@@ -82,35 +100,37 @@ spec:
82
100
  }
83
101
 
84
102
  function runtimeManifest({ context, infra, imageRef, profile = "small" }) {
103
+ const normalized = canonicalRuntimeContext(context);
104
+ const identity = normalized.deliveryTechnicalIdentity;
85
105
  const resources = RESOURCE_PROFILES[profile] || RESOURCE_PROFILES.small;
86
- const releaseLabel = kubernetesLabelValue(context.releaseId);
87
- const releaseAnnotation = JSON.stringify(String(context.releaseId));
106
+ const releaseLabel = kubernetesLabelValue(normalized.releaseId);
107
+ const releaseAnnotation = JSON.stringify(String(normalized.releaseId));
88
108
  return `apiVersion: apps/v1
89
109
  kind: Deployment
90
110
  metadata:
91
- name: ${context.appCode}
92
- namespace: ${context.namespace}
111
+ name: ${normalized.appCode}
112
+ namespace: ${normalized.namespace}
93
113
  spec:
94
114
  replicas: 1
95
115
  selector:
96
- matchLabels: { app.kubernetes.io/name: ${context.appCode} }
116
+ matchLabels: { app.kubernetes.io/name: ${normalized.appCode} }
97
117
  template:
98
118
  metadata:
99
119
  labels:
100
- app.kubernetes.io/name: ${context.appCode}
120
+ app.kubernetes.io/name: ${normalized.appCode}
101
121
  app.kubernetes.io/part-of: oon-central
102
- oon.dev/release-id: ${releaseLabel}
122
+ oon.dev/release-id: ${releaseLabel}${podIdentityLabels(identity)}
103
123
  annotations:
104
124
  oon.dev/release-id: ${releaseAnnotation}
105
125
  spec:
106
- serviceAccountName: oon-runtime
126
+ serviceAccountName: ${identity.serviceAccount}
107
127
  imagePullSecrets: [{ name: ${infra.REGISTRY_PULL_SECRET_NAME || "ghcr-oon"} }]
108
128
  containers:
109
129
  - name: central
110
130
  image: ${imageRef}
111
131
  imagePullPolicy: IfNotPresent
112
132
  ports: [{ name: http, containerPort: 8080 }]
113
- envFrom: [{ secretRef: { name: oon-runtime } }]
133
+ envFrom: [{ secretRef: { name: ${identity.secret} } }]
114
134
  resources:
115
135
  requests: { cpu: ${resources.cpuRequest}, memory: ${resources.memoryRequest} }
116
136
  limits: { cpu: ${resources.cpuLimit}, memory: ${resources.memoryLimit} }
@@ -123,28 +143,28 @@ spec:
123
143
  ---
124
144
  apiVersion: v1
125
145
  kind: Service
126
- metadata: { name: ${context.appCode}, namespace: ${context.namespace} }
146
+ metadata: { name: ${normalized.appCode}, namespace: ${normalized.namespace} }
127
147
  spec:
128
- selector: { app.kubernetes.io/name: ${context.appCode} }
148
+ selector: { app.kubernetes.io/name: ${normalized.appCode} }
129
149
  ports: [{ name: http, port: 80, targetPort: http }]
130
150
  ---
131
151
  apiVersion: networking.k8s.io/v1
132
152
  kind: Ingress
133
153
  metadata:
134
- name: ${context.appCode}
135
- namespace: ${context.namespace}
154
+ name: ${normalized.appCode}
155
+ namespace: ${normalized.namespace}
136
156
  annotations: { cert-manager.io/cluster-issuer: ${infra.CLUSTER_ISSUER} }
137
157
  spec:
138
158
  ingressClassName: ${infra.INGRESS_CLASS}
139
- tls: [{ hosts: [${context.hostname}], secretName: ${context.appCode}-tls }]
159
+ tls: [{ hosts: [${normalized.hostname}], secretName: ${identity.tlsSecret} }]
140
160
  rules:
141
- - host: ${context.hostname}
161
+ - host: ${normalized.hostname}
142
162
  http:
143
163
  paths:
144
164
  - path: /
145
165
  pathType: Prefix
146
166
  backend:
147
- service: { name: ${context.appCode}, port: { name: http } }
167
+ service: { name: ${normalized.appCode}, port: { name: http } }
148
168
  `;
149
169
  }
150
170
 
@@ -158,27 +178,41 @@ function copyPullSecret(namespace, infra) {
158
178
  } catch (error) { if (!/not found/i.test(error.message)) throw error; }
159
179
  }
160
180
 
161
- function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {} }) {
181
+ function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {}, record, writeRecord }) {
182
+ const normalized = canonicalRuntimeContext(context);
183
+ const identity = normalized.deliveryTechnicalIdentity;
162
184
  const literals = {
185
+ ...extraSecrets,
163
186
  MONGO_URI: mongoUri,
164
187
  NODE_ENV: "production",
165
- APP_ENVIRONMENT: context.environment,
166
- APP_CODE: context.appCode,
167
- PUBLIC_APP_URL: context.publicUrl,
168
- CENTRAL_ATIVACAO_URL: context.activationApiUrl,
169
- CENTRAL_ATIVACAO_API_URL: context.activationApiUrl,
188
+ APP_ENVIRONMENT: normalized.environment,
189
+ APP_CODE: normalized.appCode,
190
+ PUBLIC_APP_URL: normalized.publicUrl,
191
+ CENTRAL_ATIVACAO_URL: normalized.activationApiUrl,
192
+ CENTRAL_ATIVACAO_API_URL: normalized.activationApiUrl,
170
193
  INSTANCE_CREDENTIAL_ENCRYPTION_KEY: encryptionKey,
171
- ...extraSecrets,
194
+ ...(identity.mode === "canonical"
195
+ ? {
196
+ OON_INSTANCE_UID: identity.instanceUid,
197
+ OON_TECHNICAL_IDENTITY_VERSION: String(identity.version),
198
+ }
199
+ : {}),
172
200
  };
173
- const args = ["-n", context.namespace, "create", "secret", "generic", "oon-runtime"];
174
- for (const [key, value] of Object.entries(literals)) args.push(`--from-literal=${key}=${value}`);
175
- args.push("--dry-run=client", "-o", "yaml");
176
- applyYaml(kubectl(args, { capture: true }));
201
+ writeRecord("replace", {
202
+ ...record,
203
+ metadata: { ...record.metadata, annotations: {
204
+ ...record.metadata.annotations, "oon.runtime/mongo-state": "ready",
205
+ } },
206
+ data: { ...record.data, ...Object.fromEntries(Object.entries(literals)
207
+ .map(([key, value]) => [key, Buffer.from(String(value)).toString("base64")])) },
208
+ });
177
209
  }
178
210
 
179
211
  async function provisionRuntime({ context, extraSecrets = {} }) {
212
+ const normalized = canonicalRuntimeContext(context);
213
+ const identity = normalized.deliveryTechnicalIdentity;
180
214
  const infra = validateInfraFromCluster().config;
181
- applyYaml(baselineManifest({ context, infra }));
215
+ applyYaml(baselineManifest({ context: normalized, infra }));
182
216
 
183
217
  const platformNamespace = infra.PLATFORM_NAMESPACE || "oon-system";
184
218
  const adminSecretName = infra.MONGO_ADMIN_SECRET_NAME || "oon-mongo-admin";
@@ -205,53 +239,77 @@ async function provisionRuntime({ context, extraSecrets = {} }) {
205
239
  "DIGITALOCEAN_DATABASE_CLUSTER_ID",
206
240
  );
207
241
 
208
- let mongoUri = readSecret(context.namespace, "oon-runtime", "MONGO_URI");
209
- let databaseName = "";
210
- let credentialRotated = false;
211
-
212
- if (mongoUri) {
213
- try {
214
- await validateMongoUri(mongoUri);
215
- } catch (error) {
216
- console.warn(
217
- "A credencial MongoDB persistida não passou na validação; " +
218
- "ela será reprovisionada.",
219
- );
220
- mongoUri = null;
221
- credentialRotated = true;
222
- }
223
- }
224
-
225
- if (!mongoUri) {
226
- const provisioned = await provisionMongo({
227
- adminUri,
228
- appCode: context.appCode,
229
- environment: context.environment,
230
- provisioner: mongoProvisioner,
231
- digitalOceanToken,
232
- digitalOceanClusterId,
242
+ // Read errors are distinct from confirmed absence. Never provision on a failed GET.
243
+ let record;
244
+ try {
245
+ const raw = kubectl(["-n", normalized.namespace, "get", "secret", identity.secret,
246
+ "--ignore-not-found", "-o", "json"], { capture: true });
247
+ record = raw ? JSON.parse(raw) : null;
248
+ } catch { throw recoveryRequired("MONGO_SECRET_READ_FAILED"); }
249
+ const decode = (key) => record?.data?.[key]
250
+ ? Buffer.from(record.data[key], "base64").toString("utf8") : null;
251
+ const encryptionKey = record === null ? randomSecret(48) : decode("INSTANCE_CREDENTIAL_ENCRYPTION_KEY");
252
+ if (!encryptionKey) throw recoveryRequired("MONGO_ENCRYPTION_KEY_MISSING");
253
+ const writeRecord = (operation, value) => {
254
+ // Secrets travel through stdin and captured output, never inherited stderr/argv.
255
+ const result = spawnSync("kubectl", [operation, "-f", "-", "-o", "json"], {
256
+ input: JSON.stringify(value), encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
233
257
  });
234
- mongoUri = provisioned.mongoUri;
235
- databaseName = provisioned.dbName;
236
- credentialRotated = true;
237
- }
238
-
239
- const encryptionKey =
240
- readSecret(
241
- context.namespace,
242
- "oon-runtime",
243
- "INSTANCE_CREDENTIAL_ENCRYPTION_KEY",
244
- ) || randomSecret(48);
245
- applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets });
246
- return { infra, databaseName, credentialRotated };
258
+ if (result.status !== 0) throw recoveryRequired("MONGO_SECRET_WRITE_FAILED");
259
+ try { record = JSON.parse(result.stdout); }
260
+ catch { throw recoveryRequired("MONGO_SECRET_RESPONSE_INVALID"); }
261
+ if (!record?.metadata?.resourceVersion) throw recoveryRequired("MONGO_SECRET_RESPONSE_INVALID");
262
+ };
263
+ const mongo = await resolveMongoCredential({
264
+ identity: mongoIdentity(normalized.appCode, normalized.environment,
265
+ identity.mode === "canonical" ? { instanceUid: identity.instanceUid } : {}),
266
+ read: async () => record === null ? null : { mongoUri: decode("MONGO_URI") },
267
+ begin: async () => writeRecord("create", {
268
+ apiVersion: "v1", kind: "Secret", type: "Opaque",
269
+ metadata: { name: identity.secret, namespace: normalized.namespace,
270
+ annotations: { "oon.runtime/mongo-state": "provisioning" } },
271
+ data: { INSTANCE_CREDENTIAL_ENCRYPTION_KEY: Buffer.from(encryptionKey).toString("base64") },
272
+ }),
273
+ persist: async (uri) => writeRecord("replace", {
274
+ ...record,
275
+ metadata: { ...record.metadata, annotations: {
276
+ ...record.metadata.annotations, "oon.runtime/mongo-state": "credential-captured",
277
+ } },
278
+ data: { ...record.data, MONGO_URI: Buffer.from(uri).toString("base64") },
279
+ }),
280
+ provision: (persistCredential) => provisionMongo({
281
+ adminUri, appCode: normalized.appCode, environment: normalized.environment,
282
+ ...(identity.mode === "canonical" ? { instanceUid: identity.instanceUid } : {}),
283
+ provisioner: mongoProvisioner, digitalOceanToken, digitalOceanClusterId,
284
+ }, { persistCredential }),
285
+ validate: validateMongoUri,
286
+ });
287
+ const mongoUri = mongo.mongoUri;
288
+ const databaseName = mongoIdentity(normalized.appCode, normalized.environment,
289
+ identity.mode === "canonical" ? { instanceUid: identity.instanceUid } : {}).dbName;
290
+ const credentialRotated = false;
291
+ applyRuntimeSecret({ context: normalized, mongoUri, encryptionKey, extraSecrets, record, writeRecord });
292
+ return {
293
+ infra,
294
+ databaseName,
295
+ credentialRotated,
296
+ ...(identity.mode === "canonical" ? { technicalIdentity: identity } : {}),
297
+ };
247
298
  }
248
299
 
249
300
  function deployRuntime({ context, infra, imageDigest, resourceProfile = "small" }) {
250
- copyPullSecret(context.namespace, infra);
251
- const imageRef = `${context.imageRepository}@${imageDigest}`;
252
- applyYaml(runtimeManifest({ context, infra, imageRef, profile: resourceProfile }));
253
- kubectl(["-n", context.namespace, "rollout", "status", `deployment/${context.appCode}`, "--timeout=10m"]);
254
- return { infraVersion: infra.INFRA_SCHEMA_VERSION, imageRef };
301
+ const normalized = canonicalRuntimeContext(context);
302
+ copyPullSecret(normalized.namespace, infra);
303
+ const imageRef = `${normalized.imageRepository}@${imageDigest}`;
304
+ applyYaml(runtimeManifest({ context: normalized, infra, imageRef, profile: resourceProfile }));
305
+ kubectl(["-n", normalized.namespace, "rollout", "status", `deployment/${normalized.appCode}`, "--timeout=10m"]);
306
+ return {
307
+ infraVersion: infra.INFRA_SCHEMA_VERSION,
308
+ imageRef,
309
+ ...(normalized.deliveryTechnicalIdentity.mode === "canonical"
310
+ ? { technicalIdentity: normalized.deliveryTechnicalIdentity }
311
+ : {}),
312
+ };
255
313
  }
256
314
 
257
315
  async function waitHttp(url, { timeoutMs = 300000, expectedStatus = 200 } = {}) {
@@ -269,19 +327,20 @@ async function waitHttp(url, { timeoutMs = 300000, expectedStatus = 200 } = {})
269
327
  }
270
328
 
271
329
  async function activateMember(context) {
272
- const statusResponse = await waitHttp(`${context.publicUrl}/api/ativacao/status`);
330
+ const normalized = canonicalRuntimeContext(context);
331
+ const statusResponse = await waitHttp(`${normalized.publicUrl}/api/ativacao/status`);
273
332
  const status = await statusResponse.json();
274
333
  if (status.status === "ativa") return { alreadyActive: true, instanceId: status.instanceId };
275
- if (!context.activationCode) throw new Error("Instância não está ativa e não há código de ativação autorizado.");
276
- const response = await fetch(`${context.publicUrl}/api/ativacao/concluir`, {
334
+ if (!normalized.activationCode) throw new Error("Instância não está ativa e não há código de ativação autorizado.");
335
+ const response = await fetch(`${normalized.publicUrl}/api/ativacao/concluir`, {
277
336
  method: "POST",
278
- headers: { "Content-Type": "application/json", "Idempotency-Key": context.releaseId },
337
+ headers: { "Content-Type": "application/json", "Idempotency-Key": normalized.releaseId },
279
338
  body: JSON.stringify({
280
- codigoAtivacao: context.activationCode,
281
- nomeInstancia: `${context.appCode} dev`,
339
+ codigoAtivacao: normalized.activationCode,
340
+ nomeInstancia: `${normalized.appCode} dev`,
282
341
  parametrosGlobais: {
283
- nomeInstancia: `${context.appCode} dev`, ambiente: context.environment,
284
- urlPublica: context.publicUrl, emailAdministrativo: context.administrativeEmail,
342
+ nomeInstancia: `${normalized.appCode} dev`, ambiente: normalized.environment,
343
+ urlPublica: normalized.publicUrl, emailAdministrativo: normalized.administrativeEmail,
285
344
  idioma: "pt-BR", timezone: "America/Sao_Paulo", moeda: "BRL", pais: "BR", formatoData: "DD/MM/YYYY",
286
345
  },
287
346
  parametrosAplicacao: {},
@@ -292,4 +351,18 @@ async function activateMember(context) {
292
351
  return data;
293
352
  }
294
353
 
295
- module.exports = { RESOURCE_PROFILES, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
354
+ module.exports = {
355
+ RESOURCE_PROFILES,
356
+ randomSecret,
357
+ dnsLabel,
358
+ kubernetesLabelValue,
359
+ canonicalRuntimeContext,
360
+ provisionMongo,
361
+ validateMongoUri,
362
+ baselineManifest,
363
+ runtimeManifest,
364
+ provisionRuntime,
365
+ deployRuntime,
366
+ waitHttp,
367
+ activateMember,
368
+ };
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+
3
+ const technical = require("../core/technicalIdentity");
4
+
5
+ const SUPPORTED_DELIVERY_IDENTITY_VERSION = 1;
6
+ const CANONICAL_FIELDS = Object.freeze([
7
+ "version",
8
+ "appCode",
9
+ "instanceUid",
10
+ "environment",
11
+ "resourceBase",
12
+ "namespace",
13
+ "database",
14
+ "mongoUsername",
15
+ "serviceAccount",
16
+ "secret",
17
+ "provisionJob",
18
+ "frontendDnsLabel",
19
+ "backendDnsLabel",
20
+ "tlsSecret",
21
+ ]);
22
+ const CANONICAL_SIGNAL_FIELDS = Object.freeze([
23
+ "instanceUid",
24
+ "technicalIdentityVersion",
25
+ "technicalInstanceId",
26
+ "technicalIdentity",
27
+ ]);
28
+
29
+ function deliveryIdentityError(code, message, details = {}) {
30
+ const error = new Error(message);
31
+ error.name = "DeliveryTechnicalIdentityError";
32
+ error.code = code;
33
+ error.details = details;
34
+ return error;
35
+ }
36
+
37
+ function hasOwn(object, field) {
38
+ return Object.prototype.hasOwnProperty.call(object || {}, field);
39
+ }
40
+
41
+ function hasCanonicalIdentitySignal(context = {}) {
42
+ return CANONICAL_SIGNAL_FIELDS.some((field) => hasOwn(context, field));
43
+ }
44
+
45
+ function legacyDeliveryIdentity(context = {}) {
46
+ return Object.freeze({
47
+ mode: "legacy",
48
+ version: 0,
49
+ appCode: String(context.appCode || ""),
50
+ environment: String(context.environment || ""),
51
+ namespace: String(context.namespace || ""),
52
+ serviceAccount: "oon-runtime",
53
+ secret: "oon-runtime",
54
+ tlsSecret: `${String(context.appCode || "")}-tls`,
55
+ });
56
+ }
57
+
58
+ function assertProjection(canonical, context = {}) {
59
+ const mismatches = [];
60
+ if (context.namespace != null && String(context.namespace) !== canonical.namespace) {
61
+ mismatches.push("namespace");
62
+ }
63
+
64
+ if (hasOwn(context, "technicalIdentity")) {
65
+ const projected = context.technicalIdentity;
66
+ if (!projected || typeof projected !== "object" || Array.isArray(projected)) {
67
+ throw deliveryIdentityError(
68
+ "DELIVERY_TECHNICAL_IDENTITY_PROJECTION_INVALID",
69
+ "A projeção da identidade técnica do delivery é inválida.",
70
+ );
71
+ }
72
+ for (const field of CANONICAL_FIELDS) {
73
+ if (projected[field] == null) continue;
74
+ if (String(projected[field]) !== String(canonical[field])) mismatches.push(field);
75
+ }
76
+ }
77
+
78
+ if (mismatches.length) {
79
+ throw deliveryIdentityError(
80
+ "DELIVERY_TECHNICAL_IDENTITY_MISMATCH",
81
+ "O contexto de delivery diverge da identidade técnica canônica.",
82
+ { mismatches: [...new Set(mismatches)] },
83
+ );
84
+ }
85
+ }
86
+
87
+ function resolveDeliveryTechnicalIdentity(context = {}, { requireCanonical = false } = {}) {
88
+ if (!hasCanonicalIdentitySignal(context)) {
89
+ if (requireCanonical) {
90
+ throw deliveryIdentityError(
91
+ "DELIVERY_TECHNICAL_IDENTITY_REQUIRED",
92
+ "O contexto de delivery exige identidade técnica canônica.",
93
+ );
94
+ }
95
+ return legacyDeliveryIdentity(context);
96
+ }
97
+
98
+ if (context.instanceUid == null || context.technicalIdentityVersion == null) {
99
+ throw deliveryIdentityError(
100
+ "DELIVERY_TECHNICAL_IDENTITY_INCOMPLETE",
101
+ "O contexto de delivery possui identidade técnica parcial.",
102
+ );
103
+ }
104
+ if (Number(context.technicalIdentityVersion) !== SUPPORTED_DELIVERY_IDENTITY_VERSION) {
105
+ throw deliveryIdentityError(
106
+ "DELIVERY_TECHNICAL_IDENTITY_VERSION_UNSUPPORTED",
107
+ "A versão da identidade técnica do delivery não é suportada.",
108
+ { received: context.technicalIdentityVersion, supported: SUPPORTED_DELIVERY_IDENTITY_VERSION },
109
+ );
110
+ }
111
+
112
+ const canonical = technical.technicalIdentity({
113
+ appCode: context.appCode,
114
+ instanceUid: context.instanceUid,
115
+ environment: context.environment,
116
+ });
117
+ assertProjection(canonical, context);
118
+ return Object.freeze({ mode: "canonical", ...canonical });
119
+ }
120
+
121
+ function canonicalizeDeliveryContext(context = {}, options = {}) {
122
+ const identity = resolveDeliveryTechnicalIdentity(context, options);
123
+ if (identity.mode !== "canonical") {
124
+ return Object.freeze({ ...context, deliveryTechnicalIdentity: identity });
125
+ }
126
+ return Object.freeze({
127
+ ...context,
128
+ namespace: identity.namespace,
129
+ deliveryTechnicalIdentity: identity,
130
+ });
131
+ }
132
+
133
+ module.exports = Object.freeze({
134
+ CANONICAL_FIELDS,
135
+ CANONICAL_SIGNAL_FIELDS,
136
+ SUPPORTED_DELIVERY_IDENTITY_VERSION,
137
+ canonicalizeDeliveryContext,
138
+ hasCanonicalIdentitySignal,
139
+ resolveDeliveryTechnicalIdentity,
140
+ });