@neetru/sdk 3.1.2 → 3.1.6

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 (77) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +64 -9
  3. package/dist/auth.cjs +290 -27
  4. package/dist/auth.cjs.map +1 -1
  5. package/dist/auth.d.cts +1 -1
  6. package/dist/auth.d.ts +1 -1
  7. package/dist/auth.mjs +290 -27
  8. package/dist/auth.mjs.map +1 -1
  9. package/dist/catalog.cjs.map +1 -1
  10. package/dist/catalog.d.cts +1 -1
  11. package/dist/catalog.d.ts +1 -1
  12. package/dist/catalog.mjs.map +1 -1
  13. package/dist/checkout.cjs +22 -7
  14. package/dist/checkout.cjs.map +1 -1
  15. package/dist/checkout.d.cts +1 -1
  16. package/dist/checkout.d.ts +1 -1
  17. package/dist/checkout.mjs +22 -7
  18. package/dist/checkout.mjs.map +1 -1
  19. package/dist/db-react.d.cts +1 -1
  20. package/dist/db-react.d.ts +1 -1
  21. package/dist/db.cjs +59 -3
  22. package/dist/db.cjs.map +1 -1
  23. package/dist/db.d.cts +1 -1
  24. package/dist/db.d.ts +1 -1
  25. package/dist/db.mjs +59 -3
  26. package/dist/db.mjs.map +1 -1
  27. package/dist/entitlements.cjs.map +1 -1
  28. package/dist/entitlements.d.cts +1 -1
  29. package/dist/entitlements.d.ts +1 -1
  30. package/dist/entitlements.mjs.map +1 -1
  31. package/dist/errors.cjs.map +1 -1
  32. package/dist/errors.d.cts +11 -6
  33. package/dist/errors.d.ts +11 -6
  34. package/dist/errors.mjs.map +1 -1
  35. package/dist/firestore-compat.d.cts +1 -1
  36. package/dist/firestore-compat.d.ts +1 -1
  37. package/dist/index.cjs +302 -31
  38. package/dist/index.cjs.map +1 -1
  39. package/dist/index.d.cts +3 -3
  40. package/dist/index.d.ts +3 -3
  41. package/dist/index.mjs +302 -31
  42. package/dist/index.mjs.map +1 -1
  43. package/dist/mocks.cjs +51 -7
  44. package/dist/mocks.cjs.map +1 -1
  45. package/dist/mocks.d.cts +41 -7
  46. package/dist/mocks.d.ts +41 -7
  47. package/dist/mocks.mjs +51 -7
  48. package/dist/mocks.mjs.map +1 -1
  49. package/dist/notifications.cjs.map +1 -1
  50. package/dist/notifications.d.cts +1 -1
  51. package/dist/notifications.d.ts +1 -1
  52. package/dist/notifications.mjs.map +1 -1
  53. package/dist/react.d.cts +1 -1
  54. package/dist/react.d.ts +1 -1
  55. package/dist/support.cjs.map +1 -1
  56. package/dist/support.d.cts +1 -1
  57. package/dist/support.d.ts +1 -1
  58. package/dist/support.mjs.map +1 -1
  59. package/dist/telemetry.cjs +2 -1
  60. package/dist/telemetry.cjs.map +1 -1
  61. package/dist/telemetry.d.cts +1 -1
  62. package/dist/telemetry.d.ts +1 -1
  63. package/dist/telemetry.mjs +2 -1
  64. package/dist/telemetry.mjs.map +1 -1
  65. package/dist/{types-DePdSU3P.d.cts → types-BXvGHppn.d.cts} +113 -12
  66. package/dist/{types-B3XFHD4A.d.ts → types-CQVak2a3.d.ts} +113 -12
  67. package/dist/usage.cjs.map +1 -1
  68. package/dist/usage.d.cts +1 -1
  69. package/dist/usage.d.ts +1 -1
  70. package/dist/usage.mjs.map +1 -1
  71. package/dist/webhooks.cjs +11 -3
  72. package/dist/webhooks.cjs.map +1 -1
  73. package/dist/webhooks.d.cts +1 -1
  74. package/dist/webhooks.d.ts +1 -1
  75. package/dist/webhooks.mjs +11 -3
  76. package/dist/webhooks.mjs.map +1 -1
  77. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ All notable changes to `@neetru/sdk` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.1.5] - 2026-06-18 — **fix: drift de versão + exemplos quebrados no README**
9
+
10
+ Release **patch**. Item I4 do Conselho de UI (version/reference drift).
11
+
12
+ ### Fixed
13
+
14
+ - **`VERSION` exportado** agora bate com o `package.json` (`3.1.5`). Estava pinado em `'3.1.3'` enquanto o pacote publicado já era `3.1.4` → consumers liam a versão errada via `import { VERSION } from '@neetru/sdk'`.
15
+ - **README** parou de mostrar `usage.track()` / `usage.getQuota()` como exemplos vivos — são métodos REMOVIDOS no 3.0 (o próprio README/CHANGELOG documenta a remoção). Trocados por `usage.report(resource, qty)` (devolve `{value, limit, remaining}` inline) e `usage.check(resource)`.
16
+
8
17
  ## [3.1.0] - 2026-06-05 — **feat: subpath `firestore-compat` + hooks de dados React (frente-B P0)**
9
18
 
10
19
  Release **minor** — só adições, nenhum export removido nem assinatura alterada (a suite de snapshot SDK-QW-5 confirma que a superfície pública 3.0 segue intacta). Upgrade direto de qualquer 3.0.x.
package/README.md CHANGED
@@ -77,6 +77,42 @@ if (!check.allowed && check.behavior === 'readonly') {
77
77
 
78
78
  ## Exemplos por namespace
79
79
 
80
+ ### Login OIDC — Authorization Code + PKCE (3.1.3)
81
+
82
+ O SDK implementa o lado-**browser** do fluxo; a troca `code → tokens` acontece no
83
+ **backend do produto** (confidential client com `client_id` + `client_secret`
84
+ registrados em `oidc_clients/{id}` no Core — modelo Firebase/Facebook).
85
+
86
+ ```ts
87
+ // 1) Browser — iniciar login (gera state CSRF + nonce + PKCE S256 e redireciona)
88
+ const neetru = createNeetruClient({ oidcClientId: 'meu-app-oidc' });
89
+ await neetru.auth.signIn({
90
+ redirectUri: 'https://app.exemplo.com/auth/callback',
91
+ postLoginRedirect: '/dashboard',
92
+ });
93
+
94
+ // 2) Browser — na página de callback: valida state e devolve o material do exchange
95
+ const cb = await neetru.auth.handleRedirectCallback();
96
+ if (cb) {
97
+ // mande pro SEU backend (nunca exponha client_secret no browser)
98
+ await fetch('/api/auth/exchange', { method: 'POST', body: JSON.stringify(cb) });
99
+ location.assign(cb.postLoginRedirect ?? '/');
100
+ }
101
+
102
+ // 3) Backend do produto — troca o code por tokens no IdP Neetru
103
+ // POST https://auth.neetru.com/api/v1/oauth/token (x-www-form-urlencoded)
104
+ // Authorization: Basic base64(clientId:clientSecret)
105
+ // grant_type=authorization_code & code & redirect_uri & code_verifier
106
+ // → valide claim `nonce` do id_token === cb.nonce; sete cookie httpOnly.
107
+
108
+ // 4) Server-side — valide o id_token em qualquer request (JWKS cacheado)
109
+ const user = await neetru.auth.verifyToken(idTokenDoCookie);
110
+ ```
111
+
112
+ `auth.getIdToken()` expõe o JWT cru se o app optar por armazená-lo em
113
+ `localStorage['neetru_id_token']` (alternativa SPA; o padrão recomendado é o
114
+ cookie httpOnly do backend).
115
+
80
116
  ### Webhooks outbound (v1.2)
81
117
 
82
118
  ```ts
@@ -95,17 +131,20 @@ Eventos recebidos no seu endpoint chegam com:
95
131
  - `X-Neetru-Signature: sha256=<hmac>` (se `secret` registrado)
96
132
  - `X-Neetru-Timestamp: <ms>` (replay protection — rejeitar > 5min skew)
97
133
 
98
- #### Verificar assinatura no consumer (3.0 — async + Universal)
134
+ #### Verificar assinatura no consumer (3.1.3 — async + Universal)
135
+
136
+ O HMAC do Core cobre `` `${timestamp}.${rawBody}` `` (o timestamp é o valor de
137
+ `X-Neetru-Timestamp`). Prefira `verifyWebhookRequest`, que lê os headers,
138
+ valida o skew (5 min) e o HMAC de uma vez:
99
139
 
100
140
  ```ts
101
- import { verifyWebhookSignature } from '@neetru/sdk';
141
+ import { verifyWebhookRequest } from '@neetru/sdk';
102
142
 
103
143
  // Next.js Route Handler (Edge OU Node runtime — funciona em ambos)
104
144
  export async function POST(req: Request) {
105
145
  const raw = await req.text();
106
- const sig = req.headers.get('X-Neetru-Signature');
107
- const ok = await verifyWebhookSignature(raw, sig, process.env.NEETRU_WEBHOOK_SECRET!);
108
- if (!ok) return new Response('unauthorized', { status: 401 });
146
+ const result = await verifyWebhookRequest(raw, req.headers, process.env.NEETRU_WEBHOOK_SECRET!);
147
+ if (!result.ok) return new Response('unauthorized', { status: 401 });
109
148
 
110
149
  const event = JSON.parse(raw);
111
150
  // ... handle event
@@ -113,7 +152,19 @@ export async function POST(req: Request) {
113
152
  }
114
153
  ```
115
154
 
155
+ Verificação manual com `verifyWebhookSignature` exige passar o timestamp:
156
+
157
+ ```ts
158
+ const ok = await verifyWebhookSignature(
159
+ raw,
160
+ req.headers.get('X-Neetru-Signature'),
161
+ process.env.NEETRU_WEBHOOK_SECRET!,
162
+ req.headers.get('X-Neetru-Timestamp') ?? undefined,
163
+ );
164
+ ```
165
+
116
166
  > **Breaking 3.0**: era `boolean` sync usando `node:crypto`. Agora `Promise<boolean>` via WebCrypto.subtle — funciona em Cloudflare Workers, Vercel Edge, browsers e Node ≥20.
167
+ > **Fix 3.1.3**: até 3.1.2 o helper verificava o HMAC só sobre o body — entregas reais do Core (assinadas com `timestamp.body`) sempre falhavam. Passe o `timestamp` (ou use `verifyWebhookRequest`).
117
168
 
118
169
  ### Notifications produto → user (v1.2)
119
170
 
@@ -143,10 +194,14 @@ const detailed = await neetru.entitlements.detailed('gestovendas', 'ai_recommend
143
194
  ### Usage tracking
144
195
 
145
196
  ```ts
146
- await neetru.usage.track('report_generated', { count: 42, format: 'pdf' });
147
-
148
- const quota = await neetru.usage.getQuota('reports_per_month');
149
- // → { used: 15, limit: 100, resetsAt: '...', plan: 'starter' }
197
+ // Reporta consumo (resource, qty) devolve {value, limit, remaining} inline,
198
+ // sem precisar de uma chamada de quota separada. Idempotency-Key automático.
199
+ const usage = await neetru.usage.report('reports_generated', 1);
200
+ // → { value: 16, limit: 100, remaining: 84, status: 'ok' }
201
+
202
+ // Verifica entitlement de um resource (sem incrementar o contador).
203
+ const ent = await neetru.usage.check('reports_per_month');
204
+ // → { allowed, limit?, remaining? }
150
205
  ```
151
206
 
152
207
  ### Support
package/dist/auth.cjs CHANGED
@@ -1095,7 +1095,8 @@ function createTelemetryNamespace(config) {
1095
1095
  message: input.message
1096
1096
  };
1097
1097
  if (input.metadata) body.metadata = input.metadata;
1098
- if (input.productSlug) body.productSlug = input.productSlug;
1098
+ const productSlug = input.productSlug ?? config.productId;
1099
+ if (productSlug) body.productSlug = productSlug;
1099
1100
  let cid = input.correlationId;
1100
1101
  if (!cid) {
1101
1102
  try {
@@ -4603,13 +4604,69 @@ function createRestSyncTransport(config) {
4603
4604
  if (!config) {
4604
4605
  return { docs: [], newWatermark: Date.now(), resyncRequired: false };
4605
4606
  }
4606
- return { docs: [], newWatermark: Date.now(), resyncRequired: false };
4607
+ return { docs: [], newWatermark: Date.now(), resyncRequired: true };
4607
4608
  },
4608
- async fullResync(_collections) {
4609
+ async fullResync(collections) {
4609
4610
  if (!config) {
4610
4611
  return { docs: [], newWatermark: Date.now() };
4611
4612
  }
4612
- return { docs: [], newWatermark: Date.now() };
4613
+ const { httpRequest: httpRequest2 } = await Promise.resolve().then(() => (init_http(), http_exports));
4614
+ const tenantScopeId = config.tenantId ?? config.productId;
4615
+ const tenantHeaders = tenantScopeId ? { "x-neetru-tenant": tenantScopeId } : void 0;
4616
+ const now = Date.now();
4617
+ const docs = [];
4618
+ for (const collection of collections) {
4619
+ let raw;
4620
+ try {
4621
+ raw = await httpRequest2(config, {
4622
+ method: "GET",
4623
+ path: DATASTORE_COLLECTION_ENDPOINT(collection),
4624
+ requireAuth: true,
4625
+ retries: 2,
4626
+ headers: tenantHeaders,
4627
+ // Teto do Core é 200. Coleções > 200 docs truncam silenciosamente
4628
+ // aqui (paginação cursor é tarefa Core separada) — a detecção de
4629
+ // deleção-por-ausência do SyncEngine NÃO deve rodar sobre truncamento,
4630
+ // então mantemos o limite no máximo do servidor por ora.
4631
+ query: { limit: 200 }
4632
+ });
4633
+ } catch (err) {
4634
+ const msg = err instanceof Error ? err.message : String(err);
4635
+ throw new NeetruDbError(
4636
+ "db_unavailable",
4637
+ `[RestSyncTransport] fullResync falhou na cole\xE7\xE3o "${collection}": ${msg}`
4638
+ );
4639
+ }
4640
+ const resp = raw;
4641
+ const items = Array.isArray(resp?.items) ? resp.items : [];
4642
+ for (const item of items) {
4643
+ if (!item || typeof item !== "object") continue;
4644
+ const record = item;
4645
+ const id = typeof record["id"] === "string" ? record["id"] : null;
4646
+ if (!id) continue;
4647
+ const { id: _inlinedId, ...data } = record;
4648
+ let serverTimestamp = now;
4649
+ const updatedAt = data["_updatedAt"];
4650
+ if (typeof updatedAt === "number") {
4651
+ serverTimestamp = updatedAt;
4652
+ } else if (updatedAt !== null && typeof updatedAt === "object" && typeof updatedAt["_seconds"] === "number") {
4653
+ serverTimestamp = updatedAt["_seconds"] * 1e3;
4654
+ } else if (typeof updatedAt === "string") {
4655
+ const parsed = Date.parse(updatedAt);
4656
+ if (!Number.isNaN(parsed)) serverTimestamp = parsed;
4657
+ }
4658
+ const serverVersion = `rest_${id}_${serverTimestamp}`;
4659
+ docs.push({
4660
+ collection,
4661
+ id,
4662
+ data,
4663
+ serverVersion,
4664
+ serverTimestamp,
4665
+ deleted: false
4666
+ });
4667
+ }
4668
+ }
4669
+ return { docs, newWatermark: now };
4613
4670
  }
4614
4671
  };
4615
4672
  }
@@ -4849,6 +4906,15 @@ function createLazyCollectionRef(name, getDocsPromise) {
4849
4906
  // src/checkout.ts
4850
4907
  init_errors();
4851
4908
  init_http();
4909
+ function checkoutAuth(accessToken, method) {
4910
+ if (!accessToken || typeof accessToken !== "string") {
4911
+ throw new NeetruError(
4912
+ "validation_failed",
4913
+ `checkout.${method} requer o access_token OIDC do usu\xE1rio final (passe \`accessToken\`). A chave de API do SDK (nrt_*) N\xC3O \xE9 aceita pelo endpoint de checkout \u2014 ele autentica a identidade do cliente que est\xE1 assinando (Bearer access_token OIDC ou cookie de sess\xE3o).`
4914
+ );
4915
+ }
4916
+ return { requireAuth: false, headers: { authorization: `Bearer ${accessToken}` } };
4917
+ }
4852
4918
  function parseStartResponse(raw) {
4853
4919
  if (!raw || typeof raw !== "object") {
4854
4920
  throw new NeetruError("invalid_response", "checkout.start response is not an object");
@@ -4926,11 +4992,13 @@ function createHttpCheckoutNamespace(config) {
4926
4992
  };
4927
4993
  if (input.tenantType) body.targetTenantType = input.tenantType;
4928
4994
  if (input.tenantId) body.targetTenantId = input.tenantId;
4995
+ const { requireAuth, headers } = checkoutAuth(input.accessToken, "start");
4929
4996
  const raw = await httpRequest(config, {
4930
4997
  method: "POST",
4931
4998
  path: "/api/v1/checkout/intents",
4932
4999
  body,
4933
- requireAuth: true,
5000
+ requireAuth,
5001
+ headers,
4934
5002
  idempotencyKey: true
4935
5003
  });
4936
5004
  const result = parseStartResponse(raw);
@@ -4940,25 +5008,29 @@ function createHttpCheckoutNamespace(config) {
4940
5008
  }
4941
5009
  return result;
4942
5010
  },
4943
- async get(intentId) {
5011
+ async get(intentId, opts) {
4944
5012
  if (!intentId || typeof intentId !== "string") {
4945
5013
  throw new NeetruError("validation_failed", "checkout.get: intentId is required");
4946
5014
  }
5015
+ const { requireAuth, headers } = checkoutAuth(opts?.accessToken, "get");
4947
5016
  const raw = await httpRequest(config, {
4948
5017
  method: "GET",
4949
5018
  path: `/api/v1/checkout/intents/${encodeURIComponent(intentId)}`,
4950
- requireAuth: true
5019
+ requireAuth,
5020
+ headers
4951
5021
  });
4952
5022
  return parseGetResponse(raw);
4953
5023
  },
4954
- async cancel(intentId) {
5024
+ async cancel(intentId, opts) {
4955
5025
  if (!intentId || typeof intentId !== "string") {
4956
5026
  throw new NeetruError("validation_failed", "checkout.cancel: intentId is required");
4957
5027
  }
5028
+ const { requireAuth, headers } = checkoutAuth(opts?.accessToken, "cancel");
4958
5029
  const raw = await httpRequest(config, {
4959
5030
  method: "DELETE",
4960
5031
  path: `/api/v1/checkout/intents/${encodeURIComponent(intentId)}`,
4961
- requireAuth: true
5032
+ requireAuth,
5033
+ headers
4962
5034
  });
4963
5035
  return {
4964
5036
  ok: true,
@@ -5002,14 +5074,14 @@ var MockCheckout = class {
5002
5074
  requiresKyc: false
5003
5075
  };
5004
5076
  }
5005
- async get(intentId) {
5077
+ async get(intentId, _opts) {
5006
5078
  const found = this.intents.get(intentId);
5007
5079
  if (!found) {
5008
5080
  throw new NeetruError("not_found", `Mock intent ${intentId} not found`);
5009
5081
  }
5010
5082
  return { ...found };
5011
5083
  }
5012
- async cancel(intentId) {
5084
+ async cancel(intentId, _opts) {
5013
5085
  const found = this.intents.get(intentId);
5014
5086
  if (!found) {
5015
5087
  throw new NeetruError("not_found", `Mock intent ${intentId} not found`);
@@ -5397,9 +5469,19 @@ var DEV_FIXTURE_USER = Object.freeze({
5397
5469
  var MockAuth = class {
5398
5470
  _user;
5399
5471
  _listeners;
5400
- constructor(initialUser = null) {
5472
+ _allowUnsignedDecode;
5473
+ constructor(initialUser = null, options) {
5401
5474
  this._user = initialUser;
5402
5475
  this._listeners = /* @__PURE__ */ new Set();
5476
+ this._allowUnsignedDecode = options?.allowUnsignedDecode ?? false;
5477
+ if (this._allowUnsignedDecode) {
5478
+ const isTestEnv = typeof process !== "undefined" && (process.env?.NODE_ENV === "test" || process.env?.VITEST === "true" || process.env?.VITEST != null);
5479
+ if (!isTestEnv && typeof console !== "undefined") {
5480
+ console.warn(
5481
+ "[neetru-sdk] MockAuth.verifyToken est\xE1 em modo allowUnsignedDecode=true: tokens N\xC3O t\xEAm assinatura verificada. Este modo \xE9 exclusivo para testes \u2014 NUNCA use como security boundary em c\xF3digo deployado."
5482
+ );
5483
+ }
5484
+ }
5403
5485
  }
5404
5486
  async signIn(_options) {
5405
5487
  if (!this._user) this._user = { ...DEV_FIXTURE_USER };
@@ -5424,18 +5506,27 @@ var MockAuth = class {
5424
5506
  };
5425
5507
  }
5426
5508
  /**
5427
- * Mock de `verifyToken` — decodifica o JWT sem verificar assinatura (pra testes).
5428
- * Em dev, retorna o user atual do mock se já estiver logado. Aceita qualquer token
5429
- * não-vazio sem chamar rede.
5509
+ * Mock de `verifyToken` — por default lança `unauthorized` pra prevenir uso
5510
+ * acidental como security boundary em código deployado.
5430
5511
  *
5431
- * Para simular falhas em testes, use `MockAuth.__mockVerifyToken(fn)`.
5512
+ * Para decodificar JWTs sem verificar assinatura em testes, crie a instância
5513
+ * com `new MockAuth(user, { allowUnsignedDecode: true })`. Isso é um opt-in
5514
+ * explícito — nunca acontece silenciosamente.
5515
+ *
5516
+ * Para simular falhas ou resultados customizados, use `__mockVerifyToken(fn)`.
5432
5517
  */
5433
5518
  async verifyToken(token) {
5434
5519
  if (this._verifyTokenOverride) {
5435
5520
  return this._verifyTokenOverride(token);
5436
5521
  }
5437
5522
  if (!token) {
5438
- throw Object.assign(new Error("Token inv\xE1lido"), { code: "token_invalid_signature" });
5523
+ throw new NeetruError("token_invalid_signature", "Token inv\xE1lido: string vazia");
5524
+ }
5525
+ if (!this._allowUnsignedDecode) {
5526
+ throw new NeetruError(
5527
+ "unauthorized",
5528
+ "MockAuth.verifyToken n\xE3o verifica assinaturas JWT e N\xC3O deve ser usado como security boundary em c\xF3digo deployado. Em testes, use new MockAuth(user, { allowUnsignedDecode: true }) para habilitar decodifica\xE7\xE3o sem assinatura, ou __mockVerifyToken(fn) para injetar resultado customizado."
5529
+ );
5439
5530
  }
5440
5531
  try {
5441
5532
  const parts = token.split(".");
@@ -5462,7 +5553,32 @@ var MockAuth = class {
5462
5553
  } catch {
5463
5554
  }
5464
5555
  if (this._user) return this._user;
5465
- throw Object.assign(new Error("Nenhum user mock dispon\xEDvel"), { code: "token_invalid_signature" });
5556
+ throw new NeetruError("token_invalid_signature", "Nenhum user mock dispon\xEDvel e JWT sem sub v\xE1lido");
5557
+ }
5558
+ /**
5559
+ * Mock de `handleRedirectCallback` (Bug 2 / 3.1.3) — não há redirect real no
5560
+ * mock, então retorna `null` por default. Use `__setPendingCallback(result)`
5561
+ * em testes pra simular um retorno do IdP.
5562
+ */
5563
+ async handleRedirectCallback(_currentUrl) {
5564
+ return this._pendingCallback;
5565
+ }
5566
+ /**
5567
+ * Mock de `getIdToken` (Bug 2 / 3.1.3). Retorna `null` por default; use
5568
+ * `__setIdToken(token)` pra injetar um token cru em testes.
5569
+ */
5570
+ getIdToken() {
5571
+ return this._idToken;
5572
+ }
5573
+ _pendingCallback = null;
5574
+ _idToken = null;
5575
+ /** Test helper — simula o material devolvido por um callback OIDC. */
5576
+ __setPendingCallback(result) {
5577
+ this._pendingCallback = result;
5578
+ }
5579
+ /** Test helper — injeta o id_token cru retornado por `getIdToken`. */
5580
+ __setIdToken(token) {
5581
+ this._idToken = token;
5466
5582
  }
5467
5583
  _verifyTokenOverride = null;
5468
5584
  /**
@@ -5682,6 +5798,49 @@ function _jwtPayloadToUser(payload) {
5682
5798
  ...payload
5683
5799
  };
5684
5800
  }
5801
+ var OIDC_TX_KEY = "neetru_oauth_tx";
5802
+ function resolveIdpOrigin(baseUrl) {
5803
+ const overrideAuthUrl = typeof globalThis.NEETRU_AUTH_URL === "string" ? globalThis.NEETRU_AUTH_URL : null;
5804
+ return overrideAuthUrl ?? baseUrl.replace(/^https?:\/\/api\./, "https://auth.");
5805
+ }
5806
+ function getWebCrypto() {
5807
+ const c = globalThis.crypto;
5808
+ if (!c || typeof c.getRandomValues !== "function" || !c.subtle) {
5809
+ throw new NeetruError(
5810
+ "runtime_unsupported",
5811
+ "auth.signIn requer WebCrypto (crypto.subtle) \u2014 indispon\xEDvel neste runtime. Use HTTPS (ou http://localhost) num browser moderno."
5812
+ );
5813
+ }
5814
+ return c;
5815
+ }
5816
+ function base64UrlFromBytes(bytes) {
5817
+ let str = "";
5818
+ for (const b of bytes) str += String.fromCharCode(b);
5819
+ const b64 = typeof btoa === "function" ? btoa(str) : Buffer.from(str, "binary").toString("base64");
5820
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
5821
+ }
5822
+ function randomUrlSafe(crypto2, byteLen) {
5823
+ const bytes = new Uint8Array(byteLen);
5824
+ crypto2.getRandomValues(bytes);
5825
+ return base64UrlFromBytes(bytes);
5826
+ }
5827
+ async function pkceChallengeS256(crypto2, verifier) {
5828
+ const data = new TextEncoder().encode(verifier);
5829
+ const digest = await crypto2.subtle.digest("SHA-256", data);
5830
+ return base64UrlFromBytes(new Uint8Array(digest));
5831
+ }
5832
+ function cleanCallbackUrl(parsed) {
5833
+ try {
5834
+ const history = globalThis.history;
5835
+ if (!history || typeof history.replaceState !== "function") return;
5836
+ const clean = new URL(parsed.toString());
5837
+ for (const p of ["code", "state", "error", "error_description", "iss", "session_state"]) {
5838
+ clean.searchParams.delete(p);
5839
+ }
5840
+ history.replaceState(history.state ?? null, "", clean.pathname + clean.search + clean.hash);
5841
+ } catch {
5842
+ }
5843
+ }
5685
5844
  function createOidcAuthNamespace(config) {
5686
5845
  const STORAGE_KEY = "neetru_id_token";
5687
5846
  const listeners = /* @__PURE__ */ new Set();
@@ -5694,6 +5853,13 @@ function createOidcAuthNamespace(config) {
5694
5853
  return null;
5695
5854
  }
5696
5855
  }
5856
+ function getSessionStorage() {
5857
+ try {
5858
+ return typeof globalThis.sessionStorage !== "undefined" ? globalThis.sessionStorage : null;
5859
+ } catch {
5860
+ return null;
5861
+ }
5862
+ }
5697
5863
  function decodeJwtPayload(token) {
5698
5864
  const parts = token.split(".");
5699
5865
  if (parts.length !== 3) return null;
@@ -5777,20 +5943,48 @@ function createOidcAuthNamespace(config) {
5777
5943
  return {
5778
5944
  async signIn(options) {
5779
5945
  if (typeof globalThis.location !== "undefined" && typeof globalThis.location.assign === "function") {
5946
+ const crypto2 = getWebCrypto();
5780
5947
  const redirectUri = options?.redirectUri ?? globalThis.location.origin;
5781
5948
  const scope = options?.scope ?? "openid profile email";
5782
- const state = options?.postLoginRedirect ?? globalThis.location.href;
5783
- const overrideAuthUrl = typeof globalThis.NEETRU_AUTH_URL === "string" ? globalThis.NEETRU_AUTH_URL : null;
5784
- const idpOrigin = overrideAuthUrl ?? config.baseUrl.replace(/^https?:\/\/api\./, "https://auth.");
5949
+ let clientId = config.oidcClientId;
5950
+ if (!clientId && config.apiKey) {
5951
+ clientId = parseApiKey(config.apiKey).keyId;
5952
+ }
5953
+ if (!clientId) {
5954
+ throw new NeetruError(
5955
+ "invalid_config",
5956
+ "auth.signIn requer `oidcClientId` (ou `apiKey`) configurado para montar o client_id."
5957
+ );
5958
+ }
5959
+ const state = randomUrlSafe(crypto2, 16);
5960
+ const nonce = randomUrlSafe(crypto2, 16);
5961
+ const codeVerifier = randomUrlSafe(crypto2, 32);
5962
+ const codeChallenge = await pkceChallengeS256(crypto2, codeVerifier);
5963
+ const session = getSessionStorage();
5964
+ if (!session) {
5965
+ throw new NeetruError(
5966
+ "runtime_unsupported",
5967
+ "auth.signIn requer sessionStorage para guardar a transa\xE7\xE3o PKCE entre o redirect e o callback."
5968
+ );
5969
+ }
5970
+ const tx = {
5971
+ state,
5972
+ nonce,
5973
+ codeVerifier,
5974
+ redirectUri,
5975
+ postLoginRedirect: options?.postLoginRedirect
5976
+ };
5977
+ session.setItem(OIDC_TX_KEY, JSON.stringify(tx));
5978
+ const idpOrigin = resolveIdpOrigin(config.baseUrl);
5785
5979
  const url = new URL("/api/v1/oauth/authorize", idpOrigin);
5786
5980
  url.searchParams.set("response_type", "code");
5981
+ url.searchParams.set("client_id", clientId);
5787
5982
  url.searchParams.set("redirect_uri", redirectUri);
5788
5983
  url.searchParams.set("scope", scope);
5789
5984
  url.searchParams.set("state", state);
5790
- if (config.apiKey) {
5791
- const { keyId } = parseApiKey(config.apiKey);
5792
- url.searchParams.set("client_id", keyId);
5793
- }
5985
+ url.searchParams.set("nonce", nonce);
5986
+ url.searchParams.set("code_challenge", codeChallenge);
5987
+ url.searchParams.set("code_challenge_method", "S256");
5794
5988
  globalThis.location.assign(url.toString());
5795
5989
  return;
5796
5990
  }
@@ -5799,6 +5993,67 @@ function createOidcAuthNamespace(config) {
5799
5993
  "auth.signIn requires a browser context or mocks. Use NEETRU_ENV=dev or pass mocks.auth."
5800
5994
  );
5801
5995
  },
5996
+ async handleRedirectCallback(currentUrl) {
5997
+ const href = currentUrl ?? (typeof globalThis.location !== "undefined" ? globalThis.location.href : void 0);
5998
+ if (!href) return null;
5999
+ let parsed;
6000
+ try {
6001
+ parsed = new URL(href);
6002
+ } catch {
6003
+ return null;
6004
+ }
6005
+ const params = parsed.searchParams;
6006
+ const idpError = params.get("error");
6007
+ if (idpError) {
6008
+ const desc = params.get("error_description") ?? idpError;
6009
+ getSessionStorage()?.removeItem(OIDC_TX_KEY);
6010
+ cleanCallbackUrl(parsed);
6011
+ throw new NeetruError(idpError, `OIDC authorize falhou: ${desc}`);
6012
+ }
6013
+ const code = params.get("code");
6014
+ if (!code) return null;
6015
+ const returnedState = params.get("state");
6016
+ const session = getSessionStorage();
6017
+ const txRaw = session?.getItem(OIDC_TX_KEY) ?? null;
6018
+ let tx = null;
6019
+ if (txRaw) {
6020
+ try {
6021
+ tx = JSON.parse(txRaw);
6022
+ } catch {
6023
+ tx = null;
6024
+ }
6025
+ }
6026
+ if (!tx) {
6027
+ throw new NeetruError(
6028
+ "oidc_state_mismatch",
6029
+ "Callback OIDC sem transa\xE7\xE3o pendente \u2014 `signIn` n\xE3o foi iniciado nesta sess\xE3o (ou j\xE1 consumido)."
6030
+ );
6031
+ }
6032
+ if (!returnedState || returnedState !== tx.state) {
6033
+ session?.removeItem(OIDC_TX_KEY);
6034
+ cleanCallbackUrl(parsed);
6035
+ throw new NeetruError(
6036
+ "oidc_state_mismatch",
6037
+ "state do callback OIDC n\xE3o confere com o da transa\xE7\xE3o (poss\xEDvel CSRF)."
6038
+ );
6039
+ }
6040
+ session?.removeItem(OIDC_TX_KEY);
6041
+ cleanCallbackUrl(parsed);
6042
+ return {
6043
+ code,
6044
+ codeVerifier: tx.codeVerifier,
6045
+ redirectUri: tx.redirectUri,
6046
+ nonce: tx.nonce,
6047
+ state: tx.state,
6048
+ postLoginRedirect: tx.postLoginRedirect
6049
+ };
6050
+ },
6051
+ getIdToken() {
6052
+ const storage = getStorage();
6053
+ const token = storage?.getItem(STORAGE_KEY) ?? null;
6054
+ if (!token) return null;
6055
+ return tokenToUser(token) ? token : null;
6056
+ },
5802
6057
  async signOut() {
5803
6058
  const storage = getStorage();
5804
6059
  const storedToken = storage?.getItem(STORAGE_KEY) ?? null;
@@ -5858,13 +6113,20 @@ function createOidcAuthNamespace(config) {
5858
6113
  const issuer = idpOrigin;
5859
6114
  const jwksUrl = `${idpOrigin}/api/v1/oauth/.well-known/jwks.json`;
5860
6115
  const cacheTtlMs = options?.jwksCacheTtlMs ?? DEFAULT_JWKS_TTL_MS;
6116
+ const audienceFromOptions = options?.audience;
5861
6117
  const parsedKey = tryParseApiKey(config.apiKey);
5862
- const audience = parsedKey?.keyId;
6118
+ const audience = audienceFromOptions ?? parsedKey?.keyId;
6119
+ if (!audience) {
6120
+ throw new NeetruError(
6121
+ "invalid_config",
6122
+ "verifyToken requires an audience: provide apiKey on the client or pass options.audience. Calling jwtVerify without an audience allows tokens issued for other OIDC clients to pass."
6123
+ );
6124
+ }
5863
6125
  const jwks = _getJwks(jwksUrl, cacheTtlMs);
5864
6126
  try {
5865
6127
  const { payload } = await jose.jwtVerify(token, jwks, {
5866
6128
  issuer,
5867
- ...audience ? { audience } : {},
6129
+ audience,
5868
6130
  algorithms: ["RS256"]
5869
6131
  });
5870
6132
  return _jwtPayloadToUser(payload);
@@ -5905,6 +6167,7 @@ function createNeetruClient(config = {}) {
5905
6167
  const env = resolveEnv(config.env);
5906
6168
  const resolved = Object.freeze({
5907
6169
  apiKey,
6170
+ oidcClientId: config.oidcClientId,
5908
6171
  baseUrl,
5909
6172
  fetch: fetchImpl.bind(globalThis),
5910
6173
  env,