@cboxdk/id-js 0.15.1 → 0.18.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 (41) hide show
  1. package/README.md +455 -4
  2. package/dist/chunk-2E25IPBA.js +155 -0
  3. package/dist/chunk-2E25IPBA.js.map +1 -0
  4. package/dist/{chunk-3V6OKRWF.js → chunk-CXGKOJRJ.js} +374 -90
  5. package/dist/chunk-CXGKOJRJ.js.map +1 -0
  6. package/dist/chunk-QQW63PGU.js +25 -0
  7. package/dist/chunk-QQW63PGU.js.map +1 -0
  8. package/dist/chunk-SEUD4TE4.js +61 -0
  9. package/dist/chunk-SEUD4TE4.js.map +1 -0
  10. package/dist/{client-CecDIu_N.d.cts → client-BSZASrMo.d.ts} +109 -136
  11. package/dist/{client-CecDIu_N.d.ts → client-BxUHm5hX.d.cts} +109 -136
  12. package/dist/errors-D61xljZE.d.cts +87 -0
  13. package/dist/errors-D61xljZE.d.ts +87 -0
  14. package/dist/index.cjs +440 -35
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +40 -90
  17. package/dist/index.d.ts +40 -90
  18. package/dist/index.js +64 -25
  19. package/dist/index.js.map +1 -1
  20. package/dist/management.cjs +4372 -0
  21. package/dist/management.cjs.map +1 -0
  22. package/dist/management.d.cts +10923 -0
  23. package/dist/management.d.ts +10923 -0
  24. package/dist/management.js +4335 -0
  25. package/dist/management.js.map +1 -0
  26. package/dist/nextjs.cjs +517 -42
  27. package/dist/nextjs.cjs.map +1 -1
  28. package/dist/nextjs.d.cts +29 -8
  29. package/dist/nextjs.d.ts +29 -8
  30. package/dist/nextjs.js +42 -12
  31. package/dist/nextjs.js.map +1 -1
  32. package/dist/server.cjs +220 -0
  33. package/dist/server.cjs.map +1 -0
  34. package/dist/server.d.cts +105 -0
  35. package/dist/server.d.ts +105 -0
  36. package/dist/server.js +5 -0
  37. package/dist/server.js.map +1 -0
  38. package/dist/types-BYZldock.d.cts +316 -0
  39. package/dist/types-BYZldock.d.ts +316 -0
  40. package/package.json +14 -2
  41. package/dist/chunk-3V6OKRWF.js.map +0 -1
package/README.md CHANGED
@@ -90,6 +90,167 @@ const user = await client.authenticate({
90
90
  });
91
91
  ```
92
92
 
93
+ ## Organizations
94
+
95
+ A sign-in can be bound to one organization. The tokens then carry `org`, `org_name`, the
96
+ person's membership tier in it (`org_role`), and the app `roles` / `permissions` they hold
97
+ **there** — so switching organization means a new authorization, not a flag on the old
98
+ session.
99
+
100
+ ```ts
101
+ // Bind to an organization you already know (the person must be an active member):
102
+ await client.createAuthorizationRequest({ organization: 'org_2x…' });
103
+
104
+ // Always show the hosted organization picker, with your guess preselected:
105
+ await client.createAuthorizationRequest({
106
+ prompt: 'select_organization',
107
+ organizationHint: 'org_2x…',
108
+ });
109
+
110
+ // Hosted "create a team" step; the person becomes its owner and the sign-in
111
+ // continues bound to the new organization:
112
+ await client.createAuthorizationRequest({ prompt: 'create_organization' });
113
+ ```
114
+
115
+ | Option | Sent as | Meaning |
116
+ |---|---|---|
117
+ | `organization` | `organization` | Bind the sign-in to this organization. |
118
+ | `organizationHint` | `organization_hint` | Preselect it in the picker; the person may choose another. |
119
+ | `prompt: 'select_organization'` | `prompt=select_organization` | Always show the picker. |
120
+ | `prompt: 'create_organization'` | `prompt=create_organization` | Create an organization first. |
121
+
122
+ `organization` cannot be combined with either organization prompt — it has already made
123
+ the choice they ask the person to make — and the SDK refuses the combination rather than
124
+ sending it. Use `organizationHint` with the picker instead.
125
+
126
+ ### Switching
127
+
128
+ `switchOrganization(id)` is `createAuthorizationRequest({ organization: id })` under a name
129
+ that says what it is for. Persist and redirect exactly as for a sign-in; Cbox ID already
130
+ has the person's session, so they normally come straight back without seeing a form.
131
+
132
+ ```ts
133
+ // app/auth/switch-organization/route.ts (Next.js)
134
+ import { NextResponse, type NextRequest } from 'next/server';
135
+ import { cboxId } from '@/lib/cbox';
136
+
137
+ export async function GET(request: NextRequest) {
138
+ const org = request.nextUrl.searchParams.get('org');
139
+ if (!org) return NextResponse.redirect(new URL('/', request.url));
140
+ return cboxId.switchOrganization(org);
141
+ }
142
+ ```
143
+
144
+ **The binding is checked, not trusted.** The request echoes `organization`; persist it with
145
+ `state`, `codeVerifier` and `nonce` and pass it back as `stored.organization`, and
146
+ `authenticate()` refuses tokens for any other organization. (The Next.js adapter does this
147
+ in a cookie for you.) An instance that predates organization selection ignores the
148
+ parameter and answers for whichever organization the session already had — without the
149
+ check, your app would show the new organization's name over the old one's data.
150
+
151
+ Replace your session with the user the callback returns rather than patching the old one:
152
+ `org_role`, `roles` and `permissions` can all differ between organizations. A person who
153
+ is not (or no longer) an active member comes back with `error=access_denied`:
154
+
155
+ ```ts
156
+ import { AuthenticationError } from '@cboxdk/id-js';
157
+
158
+ try {
159
+ user = await cboxId.callback(request);
160
+ } catch (e) {
161
+ if (e instanceof AuthenticationError && e.error === 'access_denied') {
162
+ // Not a member of that organization — send them back to the one they were in.
163
+ }
164
+ }
165
+ ```
166
+
167
+ ### Listing a person's organizations
168
+
169
+ Request the `organizations` scope and `user.organizations` lists every organization the
170
+ person is an active member of — `{ id, name, role }` — for an organization switcher. It is
171
+ a separate scope because it discloses memberships across unrelated customers; a plain
172
+ `profile` sign-in does not get it.
173
+
174
+ ### Reading the claims
175
+
176
+ The signed-in user carries them typed:
177
+
178
+ ```ts
179
+ user.organization; // { id, name, role } | null — role is 'owner' | 'admin' | 'developer' | 'member' | 'viewer' | null
180
+ user.roles; // string[]
181
+ user.permissions; // string[]
182
+ user.actor; // { sub, actor } | null — see support sessions below
183
+ user.sessionId; // the id_token's `sid` | null — keep it to match a back-channel logout
184
+ ```
185
+
186
+ The same helpers work on the user and on a claim set you verified yourself, such as an
187
+ access token's payload on a resource server:
188
+
189
+ ```ts
190
+ import { organization, hasPermission, hasRole, isSupportSession } from '@cboxdk/id-js';
191
+
192
+ if (!hasPermission(payload, 'invoices:create')) return forbidden();
193
+ if (organization(user)?.role === 'owner') showBilling();
194
+ ```
195
+
196
+ Matching is exact — `invoices:*` does not grant `invoices:delete`. An `org_role` this SDK
197
+ version does not recognise reads as `null`, never as a tier it would have to guess.
198
+
199
+ ### Support sessions
200
+
201
+ A staff member can act as one of your users for a limited time (at most an hour, no refresh
202
+ token, with a recorded reason). Those tokens carry the RFC 8693 `act` claim naming the
203
+ staff member, and `isSupportSession()` reports it:
204
+
205
+ ```ts
206
+ if (isSupportSession(user)) {
207
+ // Show a banner, and refuse what a helper should never do on someone's behalf:
208
+ // changing their password, their email, their payout details.
209
+ }
210
+ ```
211
+
212
+ It is **fail-closed**: any `act` claim counts, including one whose shape the SDK cannot
213
+ read (`user.actor.sub` is then `null`). A claim it cannot parse is not evidence that nobody
214
+ else is at the keyboard.
215
+
216
+ ## From a CLI (device flow)
217
+
218
+ A command-line tool has no browser to redirect, and neither does a CI job, a container or
219
+ a TV app. The device authorization grant (RFC 8628) is for all of them: your program
220
+ prints a short code, the person approves it on whatever device is already in their hand,
221
+ and your program collects the tokens.
222
+
223
+ Register the app as **"CLI or device"** in the console. It is issued **no secret** — a
224
+ binary on somebody's laptop cannot keep one — and has **no redirect URI**, so neither
225
+ appears here:
226
+
227
+ ```ts
228
+ import { CboxIdClient } from '@cboxdk/id-js';
229
+
230
+ const cbox = new CboxIdClient({
231
+ issuer: process.env.CBOX_ID_ISSUER!,
232
+ clientId: process.env.CBOX_ID_CLIENT_ID!,
233
+ scopes: ['openid', 'profile', 'email', 'offline_access'],
234
+ });
235
+
236
+ const auth = await cbox.requestDeviceAuthorization();
237
+ console.log(`Open ${auth.verificationUri} and enter ${auth.userCode}`);
238
+
239
+ // Blocks until they approve. Honours the server's interval, backs off on `slow_down`,
240
+ // and stops on a decline or an expired code. Pass an AbortSignal so Ctrl-C works.
241
+ const user = await cbox.pollDeviceToken(auth);
242
+ console.log(`Signed in as ${user.email}`);
243
+ // Persist user.refreshToken (mode 0600) so the next run does not ask again.
244
+ ```
245
+
246
+ The scopes are bounded by what the app is **registered** for: a device request naming one
247
+ outside that ceiling is refused with `invalid_scope` rather than quietly reduced, because
248
+ no browser is in front of it to notice a smaller grant. Keep `offline_access` unless you
249
+ want the person re-approving every hour.
250
+
251
+ See [Sign in from a CLI](https://github.com/cboxdk/cbox-id/blob/main/docs/getting-started/sign-in-from-a-cli.md)
252
+ for the protocol itself and where to store the tokens.
253
+
93
254
  ## In the browser (publishable keys)
94
255
 
95
256
  Everything above needs a server: it holds your client secret. A **publishable key** is the
@@ -239,6 +400,47 @@ everywhere" needs. `machineToken`, `introspect` and `revoke` authenticate as the
239
400
  client, so they require a `clientSecret`; `userinfo` authenticates with the user's
240
401
  own access token and does not.
241
402
 
403
+ ## API keys for your API
404
+
405
+ Your customers can create API keys for **your** API on Cbox ID's hosted page, and your API
406
+ asks Cbox ID whether a key it was handed is good. Link people to the page:
407
+
408
+ ```ts
409
+ client.apiKeysUrl({ returnTo: 'https://app.acme.com/settings' });
410
+ // → {issuer}/account/api-keys?client_id=<your client>&return_to=…
411
+ // options: clientId (another of your apps), returnTo, organization (which of theirs)
412
+ ```
413
+
414
+ Verify a key on your server — never in a browser, since it uses your client secret. It
415
+ lives in its own entry, `@cboxdk/id-js/server`, so a browser bundle never pulls it in:
416
+
417
+ ```ts
418
+ import { ApiKeyVerifier } from '@cboxdk/id-js/server';
419
+ import { hasPermission } from '@cboxdk/id-js';
420
+
421
+ const keys = new ApiKeyVerifier({ issuer, clientId, clientSecret, cacheTtlMs: 10_000 });
422
+
423
+ const answer = await keys.verifyApiKey(request.headers.get('x-api-key') ?? '');
424
+ if (!answer.active) return new Response(null, { status: 401 });
425
+ if (!hasPermission(answer, 'invoices:create')) return new Response(null, { status: 403 });
426
+ // answer: { active, key_id, sub, org, org_role, permissions, client_id, expires_at }
427
+ ```
428
+
429
+ On Next.js the adapter does the same with its own configuration:
430
+ `await cboxId.verifyApiKey(key)` and `cboxId.apiKeysUrl()`.
431
+
432
+ - Every bad key — unknown, revoked, expired, another app's, a holder who left the
433
+ organization — is `{ active: false }`, with no reason. That is Cbox ID's design, so the
434
+ endpoint cannot be used to probe keys.
435
+ - `permissions` is already re-capped to what the holder holds for your app **now**; a
436
+ demotion takes effect on the next verification.
437
+ - An answer naming another app's `client_id` is refused with an `AuthenticationError`, as
438
+ is a failed call (wrong client credentials, instance unreachable). Treat a throw as "do
439
+ not let this request in".
440
+ - `cacheTtlMs` (off by default, at most 60 seconds) caches **active** answers per key, never
441
+ past the key's `expires_at`. While an answer is cached, a revoked key keeps working —
442
+ keep it short. Refusals are never cached.
443
+
242
444
  ## Migrating off an old login
243
445
 
244
446
  Bulk-importing users with their existing hashes is the first answer, and the better one.
@@ -304,6 +506,26 @@ const ok = await verifyWebhook({
304
506
  });
305
507
  ```
306
508
 
509
+ ### Standard Webhooks
510
+
511
+ An endpoint registered with `signature_scheme: 'standard_webhooks'` (or switched with
512
+ `env.webhooks.signatureScheme.change(id, { signature_scheme: 'standard_webhooks' })`) is
513
+ signed the [Standard Webhooks](https://www.standardwebhooks.com/) way: `webhook-id`,
514
+ `webhook-timestamp` and `webhook-signature`. Verify it with `verifyStandardWebhook`:
515
+
516
+ ```ts
517
+ import { verifyStandardWebhook } from '@cboxdk/id-js';
518
+
519
+ const ok = await verifyStandardWebhook({
520
+ payload: rawBody, // the exact bytes received
521
+ headers: request.headers, // a Headers object or a plain record
522
+ secret: process.env.CBOX_ID_WEBHOOK_SECRET!, // whsec_…, or the endpoint's old 64-hex secret
523
+ });
524
+ ```
525
+
526
+ It accepts any valid `v1` signature among several (sent while a secret rotates), uses the same
527
+ 5-minute freshness window, and never throws.
528
+
307
529
  ## Token Vault
308
530
 
309
531
  Broker downstream credentials (API keys for OpenAI, GitHub, …) through the instance's
@@ -334,8 +556,8 @@ claims for you to enforce. Requires the app's client to hold the `apps.manifest`
334
556
  ```ts
335
557
  import { defineAuthz, publishManifest } from '@cboxdk/id-js';
336
558
 
337
- // Declare the catalog (validated: keys are `feature:action`, roles must reference
338
- // declared permissions). Keep this next to the code that enforces it.
559
+ // Declare the catalog (validated: keys are lowercase `feature:action` slugs, and roles
560
+ // must reference declared permissions). Keep this next to the code that enforces it.
339
561
  export const authz = defineAuthz({
340
562
  permissions: [
341
563
  { key: 'invoices:create', description: 'Create invoices' },
@@ -360,11 +582,239 @@ const summary = await publishManifest(
360
582
  // → { unchanged, roles_declared, permissions_declared, ... }
361
583
  ```
362
584
 
585
+ ### Staff roles and self-serve permissions
586
+
587
+ Two flags decide who may hand something out:
588
+
589
+ - **`tenantAssignable: false` on a role** makes it a **staff role** — for your own support
590
+ or operations people, held environment-wide across every customer. Cbox ID never lists
591
+ or accepts it on an organization's own admin pages; only an environment administrator
592
+ can grant it. Roles default to `true`.
593
+ - **`tenantAssignable: true` on a permission** lets an organization's administrators put it
594
+ in custom roles they build themselves. Permissions default to `false`: internal unless
595
+ you opt in.
596
+
597
+ ```ts
598
+ export const authz = defineAuthz({
599
+ permissions: [
600
+ { key: 'parcels:read', description: 'View parcels', tenantAssignable: true },
601
+ // Lets a staff member start a support session in this app (see "Support sessions").
602
+ { key: 'support:impersonate', description: 'Act as a customer' },
603
+ ],
604
+ roles: [
605
+ { key: 'viewer', name: 'Viewer', permissions: ['parcels:read'] },
606
+ { key: 'support', name: 'Support', description: 'Our support team',
607
+ permissions: ['support:impersonate', 'parcels:read'], tenantAssignable: false },
608
+ ],
609
+ });
610
+ ```
611
+
612
+ On the wire the flags are `tenant_assignable`, and only their non-default state is sent
613
+ (`false` on a role, `true` on a permission). Both are part of the manifest's `version`, so
614
+ marking a role staff-only in a new deploy re-syncs it rather than being skipped as
615
+ unchanged. A value that is not a real boolean — the string `"false"` from a YAML file, say
616
+ — is refused, because it would otherwise read as the opposite of what it says.
617
+
363
618
  `publishManifest` mints a client-credentials token (`scope=apps.manifest`) and POSTs
364
619
  the manifest to `{issuer}/api/v1/apps/manifest`. It is a server-side operation — keep
365
620
  your `clientSecret` off the browser. The wire format matches the PHP SDK
366
621
  (`cboxdk/laravel-id-client`), so any SDK can publish the same catalog.
367
622
 
623
+ ## Management API
624
+
625
+ `@cboxdk/id-js/management` is a typed client for Cbox ID's management planes. It is
626
+ generated from the OpenAPI documents the server publishes, so every method, path, scope and
627
+ type matches the server you generated it from. Use it from server code only: every client
628
+ holds a management credential.
629
+
630
+ | Client | Plane | Credential | `baseUrl` |
631
+ | --- | --- | --- | --- |
632
+ | `EnvironmentClient` | One environment's tenancy: organizations, users, apps, roles, SSO, audit logs… | `cbid_env_…` key, or a delegated access token | The environment's own host, or the platform root with `environment` |
633
+ | `WorkspaceClient` | The workspace above its environments: projects, environments, team, keys | `cbid_ws_…` key, or a person's root token | `https://api.cboxid.com` (default) |
634
+ | `PlatformClient` | The deployment itself, for operators | Delegated operator token only | `https://api.cboxid.com` (default) |
635
+ | `AccountClient` | A person's own account | Delegated token only | The environment's own host |
636
+
637
+ Method names are the server's action names: `apps.secrets.rotate` is
638
+ `env.apps.secrets.rotate(...)`, and `sso.connections.require_sso` is
639
+ `env.sso.connections.requireSso(...)`. Path parameters come first, in path order, then the body
640
+ (or the query, for a read), then per-call options. Every call resolves to
641
+ `{ data, meta, body, status, replayed, idempotencyKey, requestId, headers }`.
642
+
643
+ ### Create an app and rotate its secret
644
+
645
+ ```ts
646
+ import { EnvironmentClient, CboxIdApiError, ApprovalDeniedError } from '@cboxdk/id-js/management';
647
+
648
+ const env = new EnvironmentClient({
649
+ baseUrl: 'https://acme.cboxid.com',
650
+ apiKey: process.env.CBOX_ID_ENV_KEY!, // cbid_env_…
651
+ onApprovalRequired: (approval, { action }) => {
652
+ console.log(`Approve ${action} on your device. Code: ${approval.binding_code}`);
653
+ },
654
+ });
655
+
656
+ const { data: app } = await env.apps.create({
657
+ name: 'Billing',
658
+ type: 'web',
659
+ redirect_uris: ['https://billing.acme.com/callback'],
660
+ });
661
+ // app.client_secret is in this response and in no other. Store it now.
662
+
663
+ try {
664
+ const { data: secret } = await env.apps.secrets.rotate(app.id, { grace_seconds: 3600 });
665
+ // secret.client_secret: the new secret, shown once.
666
+ } catch (e) {
667
+ if (e instanceof ApprovalDeniedError) console.log('Rotation was declined.');
668
+ else if (e instanceof CboxIdApiError && e.isValidationError) console.log(e.errors);
669
+ else throw e;
670
+ }
671
+ ```
672
+
673
+ When a key's policy holds an action for a person's approval, the server answers
674
+ `202 approval_required`. By default the client calls `onApprovalRequired` (show the
675
+ `binding_code` so the person can match it on their device), polls the approval, and repeats the
676
+ request with `Cbox-Approval: <id>` and the same `Idempotency-Key` once it is approved.
677
+ `ApprovalDeniedError` and `ApprovalExpiredError` are thrown when it is denied or expires. If you
678
+ do not want to wait in the same call, pass `{ approval: 'return' }`:
679
+
680
+ ```ts
681
+ const outcome = await env.apps.secrets.rotate(app.id, { grace_seconds: 0 }, { approval: 'return' });
682
+ if (outcome.pending) {
683
+ console.log(`Code: ${outcome.approval.binding_code}`);
684
+ const { data } = await outcome.resume(); // polls, then repeats the request
685
+ }
686
+ ```
687
+
688
+ ### Bootstrap an environment from a workspace key
689
+
690
+ ```ts
691
+ import { WorkspaceClient, EnvironmentClient } from '@cboxdk/id-js/management';
692
+
693
+ const workspace = new WorkspaceClient({ apiKey: process.env.CBOX_ID_WS_KEY! }); // cbid_ws_…
694
+
695
+ const { data: created } = await workspace.environments.create({
696
+ name: 'Staging',
697
+ type: 'sandbox',
698
+ initial_key: { name: 'bootstrap', scopes: ['apps:write', 'organizations:write'] },
699
+ });
700
+
701
+ // The first management key, returned once. On an idempotent replay it is null.
702
+ const env = new EnvironmentClient({ baseUrl: created!.issuer!, apiKey: created!.initial_key!.token! });
703
+ await env.organizations.create({ name: 'Acme', slug: 'acme' });
704
+ ```
705
+
706
+ ### Idempotency, retries and errors
707
+
708
+ - Every `POST`, `PUT`, `PATCH` and `DELETE` sends an `Idempotency-Key`, a fresh UUID unless you
709
+ pass `{ idempotencyKey }`. Network failures, `5xx`, `429` and `409 idempotency_in_progress` are
710
+ retried with the **same** key (`retry: { maxRetries, baseDelayMs, maxDelayMs }`; default 3
711
+ retries). `Retry-After` is respected; one longer than `maxDelayMs` is thrown instead of waited
712
+ out. `replayed` is `true` when the server returned the first request's stored answer
713
+ (`Idempotent-Replayed`). A secret in a replayed answer is `null`.
714
+ - A failed call throws `CboxIdApiError` with `status`, `error` (the stable code), `message`,
715
+ `errors` (field-keyed, on `validation_failed`), `requestId` (the envelope's `request_id`,
716
+ else the `X-Request-Id` header; quote it when reporting a problem) and `retryAfter`. A call that never
717
+ got an answer throws `ManagementNetworkError`, which carries the `idempotencyKey` so you can
718
+ repeat the request safely.
719
+ - The client never logs. Secrets in responses are returned as they arrive, and request bodies
720
+ are never put in an error.
721
+
722
+ ### One token for every environment
723
+
724
+ A person's access token issued at the platform root reaches the workspace, account and
725
+ operator planes there, and any environment of their workspace when the request names it with
726
+ `Cbox-Environment`. Pass `environment` (an id or slug) and the root host as `baseUrl`:
727
+
728
+ ```ts
729
+ const staging = new EnvironmentClient({
730
+ baseUrl: 'https://api.cboxid.com',
731
+ accessToken: () => tokens.current(), // the person's root token
732
+ environment: 'acme-staging', // sent as Cbox-Environment on every request
733
+ });
734
+ await staging.organizations.portalLinks.create(orgId, { intents: ['sso', 'dsync'] });
735
+ ```
736
+
737
+ What the token may do there is bounded by the person's role and the token's scopes. A
738
+ `cbid_env_…` key is bound to its own environment's host, so `environment` is refused with one.
739
+
740
+ ### Lists
741
+
742
+ Every paged list also has an `…All` variant that iterates every item, fetching pages as you
743
+ reach them. It follows `meta.next_cursor` on the environment plane and `meta.next_page` on the
744
+ workspace plane:
745
+
746
+ ```ts
747
+ for await (const org of env.organizations.listAll({ status: 'active' })) {
748
+ console.log(org.id, org.name);
749
+ }
750
+ ```
751
+
752
+ ### Audit Logs
753
+
754
+ Your app records what its users did, per organization (your customer), and Cbox ID keeps
755
+ each organization's events in a tamper-evident hash chain. `AuditLogger` buffers events and
756
+ sends them in batches of up to 100, each batch under its own `Idempotency-Key`. It sends a
757
+ batch when it is full, every `flushIntervalMs` (default 5 s), and on `flush()` / `close()`. A
758
+ batch that fails stays queued with the same key, so sending it again never records an event
759
+ twice:
760
+
761
+ ```ts
762
+ import { AuditLogger, exportAuditLogs, verifyAuditLogChain } from '@cboxdk/id-js/management';
763
+
764
+ const audit = new AuditLogger(env, { onError: (e) => console.error('audit flush failed', e) });
765
+
766
+ audit.record({
767
+ organization_id: org.id,
768
+ action: 'invoice.voided',
769
+ actor: { id: user.id, type: 'user', name: user.name },
770
+ targets: [{ id: invoice.id, type: 'invoice' }],
771
+ context: { location: request.ip, user_agent: request.headers['user-agent'] },
772
+ metadata: { reason: 'duplicate' },
773
+ }); // occurred_at defaults to now
774
+
775
+ await audit.close(); // on shutdown: stop the timer and send what is left
776
+ ```
777
+
778
+ Read events with `env.auditLogs.events.listAll({ organization_id, actions, range_start })`.
779
+ To get a CSV, `exportAuditLogs(env, filters)` starts an export and polls it until it is ready.
780
+ Its `url` is signed and short-lived. To check the chain yourself instead of trusting the
781
+ server's `env.auditLogs.verify()`, call `verifyAuditLogChain(env, { organization_id })` or
782
+ `verifyAuditChain(events)`. They recompute `sha256(prev_hash + canonical JSON)` exactly as
783
+ the server does: keys sorted at every depth, and slashes and Unicode unescaped. They report
784
+ `{ valid, reason, broken_at_sequence, … }`. Keep floats out of metadata you want to verify:
785
+ `1.0` and `1` arrive as the same JavaScript number.
786
+
787
+ ### Delegated tokens and DPoP
788
+
789
+ Pass `accessToken` (a string, or a function that is called before every request so it can
790
+ refresh) instead of `apiKey`. For a DPoP-bound token, pass a signer built from the key pair the
791
+ token was bound to:
792
+
793
+ ```ts
794
+ import { AccountClient, createDPoPSigner } from '@cboxdk/id-js/management';
795
+
796
+ const me = new AccountClient({
797
+ baseUrl: 'https://acme.cboxid.com',
798
+ accessToken: () => tokens.current(),
799
+ dpop: await createDPoPSigner(keyPair), // ES256, WebCrypto
800
+ });
801
+ await me.sessions.revokeOthers();
802
+ ```
803
+
804
+ ### Types, metadata and regenerating
805
+
806
+ Schema and operation types are exported per plane as namespaces (`EnvironmentApi.App`,
807
+ `WorkspaceApi.EnvironmentsCreateBody`). The operation tables (`environmentOperations`, …) list
808
+ each action's method, path, scope, danger and whether it can be held for approval, which is
809
+ useful for showing a confirmation before a `critical` action. `env.request(method, path, input)`
810
+ calls a route that is not generated.
811
+
812
+ The specs are vendored in `openapi/`. `npm run generate` (Node 22.6 or later) regenerates
813
+ `src/management/generated/` from them, and
814
+ `npm run generate -- --fetch environment=https://acme.cboxid.com` (or `workspace=`, `platform=`,
815
+ `account=`, `all=`) refreshes a vendored spec from a running server first. The test suite fails
816
+ when the generated code and the vendored specs disagree.
817
+
368
818
  ## Security & scope
369
819
 
370
820
  Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verification
@@ -372,8 +822,9 @@ Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verif
372
822
  freshness window. Keep `clientSecret` and webhook secrets server-side.
373
823
 
374
824
  This is a **client**. It authenticates users and calls a Cbox ID instance's standard
375
- endpoints; it does not configure SSO, run SCIM, or manage organizations — those are
376
- platform capabilities of [`cboxdk/laravel-id`](https://github.com/cboxdk/laravel-id).
825
+ endpoints. The management planes (organizations, apps, SSO, keys…) are reached through
826
+ [`@cboxdk/id-js/management`](#management-api), which only calls the instance's published API;
827
+ the capabilities themselves live in [`cboxdk/laravel-id`](https://github.com/cboxdk/laravel-id).
377
828
 
378
829
  Report vulnerabilities via this repo's GitHub **Private Vulnerability Reporting**.
379
830
 
@@ -0,0 +1,155 @@
1
+ import { assertSecureIssuer } from './chunk-QQW63PGU.js';
2
+ import { ConfigurationError, AuthenticationError, oauthError } from './chunk-SEUD4TE4.js';
3
+
4
+ // src/api-keys.ts
5
+ var MAX_API_KEY_CACHE_TTL_MS = 6e4;
6
+ var MAX_CACHE_ENTRIES = 1e3;
7
+ var ORGANIZATION_ROLES = /* @__PURE__ */ new Set([
8
+ "owner",
9
+ "admin",
10
+ "developer",
11
+ "member",
12
+ "viewer"
13
+ ]);
14
+ var ApiKeyVerifier = class {
15
+ constructor(config) {
16
+ this.config = config;
17
+ if (typeof globalThis.document !== "undefined") {
18
+ throw new ConfigurationError(
19
+ "API key verification uses your client secret and must run on your server, never in a browser."
20
+ );
21
+ }
22
+ if (!config.issuer || !config.clientId || !config.clientSecret) {
23
+ throw new ConfigurationError("Verifying API keys needs issuer, clientId and clientSecret.");
24
+ }
25
+ assertSecureIssuer(config.issuer);
26
+ const cacheTtlMs = config.cacheTtlMs ?? 0;
27
+ if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0 || cacheTtlMs > MAX_API_KEY_CACHE_TTL_MS) {
28
+ throw new ConfigurationError(
29
+ `cacheTtlMs must be between 0 and ${MAX_API_KEY_CACHE_TTL_MS}: a cached answer keeps a revoked key working for that long.`
30
+ );
31
+ }
32
+ this.endpoint = `${config.issuer.replace(/\/$/, "")}/oauth/api-keys/verify`;
33
+ this.timeoutMs = config.timeoutMs ?? 1e4;
34
+ this.cacheTtlMs = cacheTtlMs;
35
+ }
36
+ config;
37
+ endpoint;
38
+ timeoutMs;
39
+ cacheTtlMs;
40
+ cache = /* @__PURE__ */ new Map();
41
+ /**
42
+ * Ask Cbox ID whether `key` is good for this app, and what it may do.
43
+ *
44
+ * Resolves `{ active: false }` for every kind of bad key, without a request for an empty
45
+ * one. Throws `AuthenticationError` when the call itself fails (wrong client
46
+ * credentials, an unreachable instance) or when the answer cannot be trusted — one that
47
+ * names another app's `client_id`, or does not have the promised shape. Both mean "do
48
+ * not let this request in"; the difference is that a throw is something to look at.
49
+ */
50
+ async verifyApiKey(key) {
51
+ if (typeof key !== "string" || key.trim() === "") {
52
+ return { active: false };
53
+ }
54
+ const cacheKey = this.cacheTtlMs > 0 ? await digest(key) : null;
55
+ if (cacheKey !== null) {
56
+ const hit = this.cache.get(cacheKey);
57
+ if (hit && hit.until > Date.now()) {
58
+ return hit.answer;
59
+ }
60
+ this.cache.delete(cacheKey);
61
+ }
62
+ const answer = this.parse(await this.request(key));
63
+ if (cacheKey !== null && answer.active) {
64
+ this.remember(cacheKey, answer);
65
+ }
66
+ return answer;
67
+ }
68
+ async request(key) {
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
71
+ let response;
72
+ try {
73
+ response = await fetch(this.endpoint, {
74
+ method: "POST",
75
+ headers: {
76
+ authorization: `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,
77
+ accept: "application/json",
78
+ "content-type": "application/x-www-form-urlencoded"
79
+ },
80
+ body: new URLSearchParams({ key }),
81
+ signal: controller.signal
82
+ });
83
+ } catch (error) {
84
+ throw new AuthenticationError(`API key verification request failed: ${String(error)}`);
85
+ } finally {
86
+ clearTimeout(timer);
87
+ }
88
+ if (!response.ok) {
89
+ throw await oauthError(response, "API key verification failed");
90
+ }
91
+ return response.json().catch(() => null);
92
+ }
93
+ parse(body) {
94
+ if (typeof body !== "object" || body === null) {
95
+ throw new AuthenticationError("API key verification returned a body that is not a JSON object.");
96
+ }
97
+ const record = body;
98
+ if (record["active"] !== true) {
99
+ return { active: false };
100
+ }
101
+ const keyId = record["key_id"];
102
+ const sub = record["sub"];
103
+ const org = record["org"];
104
+ const clientId = record["client_id"];
105
+ const expiresAt = record["expires_at"];
106
+ const permissions = record["permissions"];
107
+ if (!nonEmpty(keyId) || !nonEmpty(sub) || !nonEmpty(org) || !nonEmpty(clientId) || !Array.isArray(permissions) || expiresAt !== null && expiresAt !== void 0 && typeof expiresAt !== "string") {
108
+ throw new AuthenticationError("API key verification returned an active answer without the promised fields.");
109
+ }
110
+ if (clientId !== this.config.clientId) {
111
+ throw new AuthenticationError(
112
+ `API key verification answered for client ${clientId}, not this app (${this.config.clientId}); refusing it.`
113
+ );
114
+ }
115
+ if (typeof expiresAt === "string") {
116
+ const expiry = Date.parse(expiresAt);
117
+ if (Number.isNaN(expiry) || expiry <= Date.now()) {
118
+ return { active: false };
119
+ }
120
+ }
121
+ const role = record["org_role"];
122
+ return {
123
+ active: true,
124
+ key_id: keyId,
125
+ sub,
126
+ org,
127
+ org_role: typeof role === "string" && ORGANIZATION_ROLES.has(role) ? role : null,
128
+ permissions: permissions.filter((value) => nonEmpty(value)),
129
+ client_id: clientId,
130
+ expires_at: typeof expiresAt === "string" ? expiresAt : null
131
+ };
132
+ }
133
+ remember(cacheKey, answer) {
134
+ const expiry = answer.expires_at === null ? Number.POSITIVE_INFINITY : Date.parse(answer.expires_at);
135
+ const until = Math.min(Date.now() + this.cacheTtlMs, expiry);
136
+ if (this.cache.size >= MAX_CACHE_ENTRIES) {
137
+ const oldest = this.cache.keys().next();
138
+ if (!oldest.done) {
139
+ this.cache.delete(oldest.value);
140
+ }
141
+ }
142
+ this.cache.set(cacheKey, { answer, until });
143
+ }
144
+ };
145
+ function nonEmpty(value) {
146
+ return typeof value === "string" && value !== "";
147
+ }
148
+ async function digest(key) {
149
+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
150
+ return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
151
+ }
152
+
153
+ export { ApiKeyVerifier, MAX_API_KEY_CACHE_TTL_MS };
154
+ //# sourceMappingURL=chunk-2E25IPBA.js.map
155
+ //# sourceMappingURL=chunk-2E25IPBA.js.map