cauthy-auth 0.28.1 → 0.31.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 (46) hide show
  1. package/README.md +61 -3
  2. package/dist/{cauthy-CTVg4kvo.d.mts → cauthy-2jtP2LVQ.d.mts} +19 -1
  3. package/dist/{cauthy-BAwAJChs.d.ts → cauthy-CKfvvv_i.d.ts} +19 -1
  4. package/dist/cli.js +163 -59
  5. package/dist/client.d.mts +91 -10
  6. package/dist/client.d.ts +91 -10
  7. package/dist/client.js +39 -3
  8. package/dist/client.js.map +1 -1
  9. package/dist/client.mjs +39 -3
  10. package/dist/client.mjs.map +1 -1
  11. package/dist/express.d.mts +2 -2
  12. package/dist/express.d.ts +2 -2
  13. package/dist/fastify.d.mts +2 -2
  14. package/dist/fastify.d.ts +2 -2
  15. package/dist/hono.d.mts +2 -2
  16. package/dist/hono.d.ts +2 -2
  17. package/dist/index.d.mts +2 -2
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.js +387 -148
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +385 -148
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/next.d.mts +2 -2
  24. package/dist/next.d.ts +2 -2
  25. package/dist/react.d.mts +275 -23
  26. package/dist/react.d.ts +275 -23
  27. package/dist/react.js +312 -78
  28. package/dist/react.js.map +1 -1
  29. package/dist/react.mjs +311 -78
  30. package/dist/react.mjs.map +1 -1
  31. package/dist/styles.css +8 -0
  32. package/dist/svelte.d.mts +2 -2
  33. package/dist/svelte.d.ts +2 -2
  34. package/dist/svelte.js +53 -4
  35. package/dist/svelte.js.map +1 -1
  36. package/dist/svelte.mjs +53 -4
  37. package/dist/svelte.mjs.map +1 -1
  38. package/dist/{system-CVBW_AV8.d.mts → system-BrrJsrFW.d.mts} +321 -109
  39. package/dist/{system-Bn8uckvQ.d.ts → system-BvvoNs2y.d.ts} +321 -109
  40. package/dist/vue.d.mts +2 -2
  41. package/dist/vue.d.ts +2 -2
  42. package/dist/vue.js +53 -4
  43. package/dist/vue.js.map +1 -1
  44. package/dist/vue.mjs +53 -4
  45. package/dist/vue.mjs.map +1 -1
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -47,16 +47,21 @@ const { signIn } = useAuth()
47
47
  const r = await signIn.email({ email, password }) // r.ok, r.status, r.error.field
48
48
  ```
49
49
 
50
- What Cauthy mails opens your pages too: a new password, a confirmed address, a sign-in link.
50
+ What Cauthy mails opens your pages too: a new password, a confirmed address, a sign-in link, an invitation.
51
51
 
52
52
  ```ts
53
- export const auth = createCauthy({ pages: { signIn: '/sign-in', resetPassword: '/new-password', verifyEmail: '/confirm' } })
53
+ export const auth = createCauthy({ pages: { signIn: '/sign-in', resetPassword: '/new-password', verifyEmail: '/confirm', acceptInvite: '/accept-invite' } })
54
54
  ```
55
55
 
56
56
  ```tsx
57
- import { ResetPassword, VerifyEmail } from 'cauthy-auth/react'
57
+ import { AcceptInvite, ResetPassword, VerifyEmail } from 'cauthy-auth/react'
58
+ <AcceptInvite links={{ signIn: '/sign-in' }} returnTo="/" /> // "You've been invited to Acme", a password, signed in
59
+
60
+ const { invitation, acceptInvitation } = useAuth() // or with your own form
58
61
  ```
59
62
 
63
+ The forms follow what the workspace turned on in Cauthy's dashboard: each provider's button carries its logo (`classNames.providerIcon` styles it), and a workspace that turned off the password or passkeys does not see them offered. `methods()` says so: `passwordSignIn`, `passkey`, `providers[].iconUrl`.
64
+
60
65
  And the person's account, in your application: name, password, second step, sessions.
61
66
 
62
67
  ```tsx
@@ -64,6 +69,33 @@ import { Account } from 'cauthy-auth/react'
64
69
  <Account /> // or: const { account } = useAuth()
65
70
  ```
66
71
 
72
+ And the person's organizations, run by their own members: the people of one, and the organization itself. Cauthy decides each change by the person's role in the system's catalog, with their own session:
73
+
74
+ ```tsx
75
+ import { OrganizationMembers } from 'cauthy-auth/react'
76
+ <OrganizationMembers organizationId={org.id} /> // shows only what the person's role allows
77
+
78
+ const { organizations } = useAuth()
79
+ const [org] = await organizations.list() // org.can: { readMembers, manageMembers, removeMembers, update, archive, delete }
80
+ await organizations.add(org.id, { email: 'ana@example.com', role: 'reader' })
81
+ await organizations.setRole(org.id, userId, 'admin')
82
+ await organizations.remove(org.id, userId) // anybody may remove themselves: leave
83
+ await organizations.rename(org.id, 'Acme Books') // r.organization
84
+ await organizations.archive(org.id) // only the workspace restores it
85
+ await organizations.delete(org.id) // for good
86
+ ```
87
+
88
+ | Reserved power | Lets a member |
89
+ | --- | --- |
90
+ | `cauthy:members:read` | see the people (`can.readMembers`) |
91
+ | `cauthy:members:manage` | add and invite people, change their role (`can.manageMembers`) |
92
+ | `cauthy:members:remove` | remove another member (`can.removeMembers`) |
93
+ | `cauthy:organization:update` | rename the organization (`can.update`) |
94
+ | `cauthy:organization:archive` | archive it (`can.archive`) |
95
+ | `cauthy:organization:delete` | delete it for good (`can.delete`) |
96
+
97
+ Every catalog has a reserved role, `owner`, with all of them. Only an owner gives the owner role, or changes or removes another owner, and an organization always keeps one: its last owner cannot be removed, demoted or leave (`last_owner`). On the server they read like any other permission: `can(organization, 'cauthy:organization', 'delete')`.
98
+
67
99
  Roles and permissions with their names, from the catalog Cauthy has:
68
100
 
69
101
  ```bash
@@ -75,6 +107,30 @@ import { can, hasRole } from './policy'
75
107
  can(organization, 'ledger:write') // only what the catalog names compiles
76
108
  ```
77
109
 
110
+ A system may also declare its resources: their actions, and the attributes a condition reads. A permission may then carry a condition, in CEL (`resource.attrs.amount <= 1000000`, `resource.owner_id == subject.id`). Such a permission is not in `permissions`: it comes in `organization.conditional`, `can` never counts it, and Cauthy decides it per question:
111
+
112
+ ```ts
113
+ const r = await auth.check(request, { organization, resource: 'invoices', action: 'approve', attributes: { amount: 500 } })
114
+ r.allowed, r.reason, r.rule // rule: which role or exception decided
115
+
116
+ await organizations.check({ organization, resource: 'invoices', action: 'approve', attributes: { amount: 500 } }) // in the browser
117
+ await auth.system.check({ organizationId, userId, resource: 'invoices', action: 'read' }) // about any member
118
+ ```
119
+
120
+ `--pull` writes the declared resources too, and a check is typed by them:
121
+
122
+ ```ts
123
+ export const policy = definePolicy({
124
+ catalog: [{ resource: 'invoices', actions: ['read', 'approve'], attributes: { amount: 'number', branch: 'string' } }],
125
+ roles: [{ role: 'accountant', permissions: [{ resource: 'invoices', action: 'approve', condition: 'resource.attrs.amount <= 1000000' }] }],
126
+ })
127
+
128
+ await policy.check(auth, request, { organization, resource: 'invoices', action: 'approve', attributes: { amount: 500 } })
129
+ // 'payroll', action 'delete' or amount: '500' do not compile
130
+ ```
131
+
132
+ `npx cauthy-auth policy --file policy.json --push` loads a first catalog: a list of roles, or `{ "catalog": [...], "roles": [...] }`, whose resources go first so the roles are checked against them. Pushing takes a personal access token with `systems:policy`.
133
+
78
134
  Each framework has its own few lines:
79
135
 
80
136
  | | |
@@ -115,6 +171,8 @@ const org = await auth.system.organizations.create({ name: 'Acme' })
115
171
  await auth.system.members.add(org.id, { role: 'owner', email: 'ana@acme.com' })
116
172
  ```
117
173
 
174
+ Its invitations open your `pages.acceptInvite` when the application knows its public origin (`baseURL`, `CAUTHY_BASE_URL`); otherwise Cauthy's own page.
175
+
118
176
  A backend that is handed a Cauthy access token checks it with Cauthy's published keys: no secret, no call per request.
119
177
 
120
178
  ```ts
@@ -1,4 +1,4 @@
1
- import { J as JsonValue, S as Session, l as SessionStatus, k as SessionOrganization, c as CauthySystem, i as PublicSession, b as CauthyOptions } from './system-CVBW_AV8.mjs';
1
+ import { J as JsonValue, S as Session, t as SessionStatus, s as SessionOrganization, f as CheckQuestion, k as OrganizationRef, g as CheckResult, d as CauthySystem, q as PublicSession, c as CauthyOptions } from './system-BrrJsrFW.mjs';
2
2
  import { JWTVerifyGetKey } from 'jose';
3
3
 
4
4
  /** Something that happened in Cauthy, as a subscriber is told. Moments are UTC. */
@@ -80,6 +80,24 @@ interface Cauthy {
80
80
  }>;
81
81
  /** The organizations the person belongs to (of `system`, when set), with their role in each. */
82
82
  organizations(session: Session): Promise<SessionOrganization[]>;
83
+ /**
84
+ * Asks Cauthy whether the signed-in person may do an action on a resource
85
+ * in an organization, with what its conditions read (`attributes`, whose
86
+ * the record is). It is how a permission with a condition is decided —
87
+ * `can` never counts those — and it decides any other the same way.
88
+ *
89
+ * const r = await auth.check(request, { organization, resource: 'invoices', action: 'approve', attributes: { amount: 500 } })
90
+ * if (!r.allowed) return forbidden(r.reason)
91
+ *
92
+ * Nobody signed in, an organization the person is not in, or a disabled
93
+ * account answer `allowed: false`. A request whose session must be renewed
94
+ * first reads as nobody signed in: give it the `Session` from
95
+ * `getSession(request, responseHeaders)` instead. Throws `CauthyApiError`
96
+ * when Cauthy refuses the question itself (a malformed one) or cannot be reached.
97
+ */
98
+ check(who: Request | Session, question: CheckQuestion & {
99
+ organization: OrganizationRef;
100
+ }): Promise<CheckResult>;
83
101
  /**
84
102
  * Verifies a webhook delivery from Cauthy and answers the event it carries;
85
103
  * throws `CauthyWebhookError` for anything else. No secret is involved: the
@@ -1,4 +1,4 @@
1
- import { J as JsonValue, S as Session, l as SessionStatus, k as SessionOrganization, c as CauthySystem, i as PublicSession, b as CauthyOptions } from './system-Bn8uckvQ.js';
1
+ import { J as JsonValue, S as Session, t as SessionStatus, s as SessionOrganization, f as CheckQuestion, k as OrganizationRef, g as CheckResult, d as CauthySystem, q as PublicSession, c as CauthyOptions } from './system-BvvoNs2y.js';
2
2
  import { JWTVerifyGetKey } from 'jose';
3
3
 
4
4
  /** Something that happened in Cauthy, as a subscriber is told. Moments are UTC. */
@@ -80,6 +80,24 @@ interface Cauthy {
80
80
  }>;
81
81
  /** The organizations the person belongs to (of `system`, when set), with their role in each. */
82
82
  organizations(session: Session): Promise<SessionOrganization[]>;
83
+ /**
84
+ * Asks Cauthy whether the signed-in person may do an action on a resource
85
+ * in an organization, with what its conditions read (`attributes`, whose
86
+ * the record is). It is how a permission with a condition is decided —
87
+ * `can` never counts those — and it decides any other the same way.
88
+ *
89
+ * const r = await auth.check(request, { organization, resource: 'invoices', action: 'approve', attributes: { amount: 500 } })
90
+ * if (!r.allowed) return forbidden(r.reason)
91
+ *
92
+ * Nobody signed in, an organization the person is not in, or a disabled
93
+ * account answer `allowed: false`. A request whose session must be renewed
94
+ * first reads as nobody signed in: give it the `Session` from
95
+ * `getSession(request, responseHeaders)` instead. Throws `CauthyApiError`
96
+ * when Cauthy refuses the question itself (a malformed one) or cannot be reached.
97
+ */
98
+ check(who: Request | Session, question: CheckQuestion & {
99
+ organization: OrganizationRef;
100
+ }): Promise<CheckResult>;
83
101
  /**
84
102
  * Verifies a webhook delivery from Cauthy and answers the event it carries;
85
103
  * throws `CauthyWebhookError` for anything else. No secret is involved: the
package/dist/cli.js CHANGED
@@ -368,73 +368,147 @@ ${declaration}() {
368
368
 
369
369
  // src/permissions.ts
370
370
  var IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
371
- function policySource(system, roles) {
371
+ function policySource(system, read) {
372
+ const roles = Array.isArray(read) ? read : read.roles;
373
+ const catalog = Array.isArray(read) ? [] : read.catalog ?? [];
372
374
  const text = (v) => JSON.stringify(v);
373
375
  const key = (k) => IDENT.test(k) ? k : text(k);
374
- const one = (r) => {
376
+ const one = (r, pad) => {
375
377
  const parts = [`role: ${text(r.role)}`];
376
378
  if (r.name) parts.push(`name: ${text(r.name)}`);
377
379
  if (r.description) parts.push(`description: ${text(r.description)}`);
378
380
  if (r.inherits?.length) parts.push(`inherits: [${r.inherits.map(text).join(", ")}]`);
379
381
  if (r.permissions?.length) {
380
- const list = r.permissions.map((p) => ` { ${[...p.kind && p.kind !== "*" ? [`${key("kind")}: ${text(p.kind)}`] : [], `resource: ${text(p.resource)}`, `action: ${text(p.action)}`].join(", ")} },`);
382
+ const list = r.permissions.map((p) => `${pad} { ${[
383
+ ...p.kind && p.kind !== "*" ? [`${key("kind")}: ${text(p.kind)}`] : [],
384
+ `resource: ${text(p.resource)}`,
385
+ `action: ${text(p.action)}`,
386
+ ...p.condition ? [`condition: ${text(p.condition)}`] : []
387
+ ].join(", ")} },`);
381
388
  parts.push(`permissions: [
382
389
  ${list.join("\n")}
383
- ]`);
390
+ ${pad} ]`);
384
391
  }
385
- return ` {
386
- ${parts.join(",\n ")},
387
- },`;
392
+ return `${pad} {
393
+ ${pad} ${parts.join(`,
394
+ ${pad} `)},
395
+ ${pad} },`;
388
396
  };
389
- return `// The catalog of roles of ${text(system)}, as Cauthy has it.
397
+ const resource = (c) => {
398
+ const parts = [`resource: ${text(c.resource)}`];
399
+ if (c.description) parts.push(`description: ${text(c.description)}`);
400
+ parts.push(`actions: [${c.actions.map(text).join(", ")}]`);
401
+ const attrs = Object.entries(c.attributes ?? {});
402
+ if (attrs.length) parts.push(`attributes: { ${attrs.map(([k, v]) => `${key(k)}: ${text(v)}`).join(", ")} }`);
403
+ return ` { ${parts.join(", ")} },`;
404
+ };
405
+ const head = `// The catalog of roles of ${text(system)}, as Cauthy has it.
390
406
  // Written by \`cauthy-auth policy --pull\`: run it again after the catalog
391
407
  // changes, and do not edit this file by hand.
392
-
408
+ `;
409
+ if (catalog.length === 0) {
410
+ return `${head}
393
411
  import { definePolicy, type PermissionOf, type RoleOf } from 'cauthy-auth'
394
412
 
395
413
  export const policy = definePolicy([
396
- ${roles.map(one).join("\n")}
414
+ ${roles.map((r) => one(r, "")).join("\n")}
397
415
  ])
398
416
 
399
417
  export const { can, hasRole } = policy
400
418
  export type Role = RoleOf<typeof policy>
401
419
  export type Permission = PermissionOf<typeof policy>
402
420
  `;
403
- }
404
- async function pullPolicy(o = {}) {
405
- const env2 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
406
- const issuer = withoutTrailingSlashes(o.issuer ?? env2("CAUTHY_ISSUER") ?? "");
407
- const system = o.system ?? env2("CAUTHY_SYSTEM") ?? "";
408
- const tenant = o.tenant ?? env2("CAUTHY_TENANT") ?? "";
409
- const token = o.token ?? env2("CAUTHY_TOKEN") ?? "";
410
- for (const [name, v] of Object.entries({ CAUTHY_ISSUER: issuer, CAUTHY_SYSTEM: system, CAUTHY_TENANT: tenant, CAUTHY_TOKEN: token })) {
411
- if (v === "") throw new Error(`cauthy-auth: ${name} is required to read a policy`);
412
421
  }
413
- const res = await (o.fetch ?? fetch)(`${issuer}/v1/systems/${encodeURIComponent(system)}/policy`, {
414
- headers: { authorization: `Bearer ${token}`, "x-tenant-key": tenant, accept: "application/json" }
415
- });
416
- const body = await res.json().catch(() => null);
417
- if (!res.ok) throw new Error(`cauthy-auth: Cauthy refused to give the policy (${res.status}): ${body?.errors?.[0]?.message ?? res.statusText}`);
418
- return body?.data?.roles ?? [];
422
+ return `${head}
423
+ import { definePolicy, type CheckOf, type PermissionOf, type RoleOf } from 'cauthy-auth'
424
+
425
+ export const policy = definePolicy({
426
+ catalog: [
427
+ ${catalog.map(resource).join("\n")}
428
+ ],
429
+ roles: [
430
+ ${roles.map((r) => one(r, " ")).join("\n")}
431
+ ],
432
+ })
433
+
434
+ export const { can, hasRole } = policy
435
+ export type Role = RoleOf<typeof policy>
436
+ export type Permission = PermissionOf<typeof policy>
437
+ /** What a check may ask: \`policy.check(auth, request, { organization, ...question })\`. */
438
+ export type Check = CheckOf<typeof policy>
439
+ `;
419
440
  }
420
- async function pushPolicy(o) {
441
+ function reach(o, doing) {
421
442
  const env2 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
422
- const issuer = withoutTrailingSlashes(o.issuer ?? env2("CAUTHY_ISSUER") ?? "");
423
- const system = o.system ?? env2("CAUTHY_SYSTEM") ?? "";
424
- const tenant = o.tenant ?? env2("CAUTHY_TENANT") ?? "";
425
- const token = o.token ?? env2("CAUTHY_TOKEN") ?? "";
426
- for (const [name, v] of Object.entries({ CAUTHY_ISSUER: issuer, CAUTHY_SYSTEM: system, CAUTHY_TENANT: tenant, CAUTHY_TOKEN: token })) {
427
- if (v === "") throw new Error(`cauthy-auth: ${name} is required to push a policy`);
443
+ const r = {
444
+ issuer: withoutTrailingSlashes(o.issuer ?? env2("CAUTHY_ISSUER") ?? ""),
445
+ system: o.system ?? env2("CAUTHY_SYSTEM") ?? "",
446
+ tenant: o.tenant ?? env2("CAUTHY_TENANT") ?? "",
447
+ token: o.token ?? env2("CAUTHY_TOKEN") ?? ""
448
+ };
449
+ for (const [name, v] of Object.entries({ CAUTHY_ISSUER: r.issuer, CAUTHY_SYSTEM: r.system, CAUTHY_TENANT: r.tenant, CAUTHY_TOKEN: r.token })) {
450
+ if (v === "") throw new Error(`cauthy-auth: ${name} is required to ${doing}`);
428
451
  }
429
- const res = await (o.fetch ?? fetch)(`${issuer}/v1/systems/${encodeURIComponent(system)}/policy`, {
430
- method: "PUT",
431
- headers: { authorization: `Bearer ${token}`, "x-tenant-key": tenant, "content-type": "application/json" },
432
- body: JSON.stringify({ roles: o.roles })
452
+ return { ...r, fetch: o.fetch ?? fetch };
453
+ }
454
+ async function systemCall(r, method, what, body) {
455
+ const res = await r.fetch(`${r.issuer}/v1/systems/${encodeURIComponent(r.system)}/${what}`, {
456
+ ...method === "PUT" ? { method } : {},
457
+ headers: {
458
+ authorization: `Bearer ${r.token}`,
459
+ "x-tenant-key": r.tenant,
460
+ ...body === void 0 ? { accept: "application/json" } : { "content-type": "application/json" }
461
+ },
462
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
433
463
  });
464
+ const answer = await res.json().catch(() => null);
434
465
  if (!res.ok) {
435
- const body = await res.json().catch(() => null);
436
- throw new Error(`cauthy-auth: Cauthy refused the policy (${res.status}): ${body?.errors?.[0]?.message ?? res.statusText}`);
466
+ const verb = method === "GET" ? `refused to give the ${what}` : `refused the ${what}`;
467
+ throw new Error(`cauthy-auth: Cauthy ${verb} (${res.status}): ${answer?.errors?.[0]?.message ?? res.statusText}`);
437
468
  }
469
+ return answer?.data;
470
+ }
471
+ async function pullPolicy(o = {}) {
472
+ const data = await systemCall(reach(o, "read a policy"), "GET", "policy");
473
+ return data?.roles ?? [];
474
+ }
475
+ async function pullCatalog(o = {}) {
476
+ const data = await systemCall(reach(o, "read a catalog"), "GET", "catalog");
477
+ return data?.resources ?? [];
478
+ }
479
+ async function pushPolicy(o) {
480
+ const r = reach(o, "push a policy");
481
+ if (o.catalog !== void 0) await systemCall(r, "PUT", "catalog", { resources: o.catalog });
482
+ await systemCall(r, "PUT", "policy", { roles: o.roles });
483
+ }
484
+ function readPolicyFile(parsed) {
485
+ if (Array.isArray(parsed)) return { catalog: [], roles: parsed };
486
+ const o = parsed ?? {};
487
+ if (!Array.isArray(o.roles)) throw new Error('no "roles" list');
488
+ if (o.catalog !== void 0 && !Array.isArray(o.catalog)) throw new Error('"catalog" is not a list');
489
+ return { catalog: o.catalog ?? [], roles: o.roles };
490
+ }
491
+ function checkWire(q) {
492
+ return {
493
+ resource: q.resource,
494
+ action: q.action,
495
+ ...q.resourceId !== void 0 ? { resource_id: q.resourceId } : {},
496
+ ...q.ownerId !== void 0 ? { owner_id: q.ownerId } : {},
497
+ ...q.attributes !== void 0 ? { attributes: q.attributes } : {}
498
+ };
499
+ }
500
+ function checkResultOf(d) {
501
+ const r = d?.rule;
502
+ const rule = r && typeof r === "object" ? {
503
+ source: r.source === "exception" ? "exception" : "role",
504
+ ...r.role ? { role: String(r.role) } : {},
505
+ kind: String(r.kind ?? "*"),
506
+ resource: String(r.resource ?? ""),
507
+ action: String(r.action ?? ""),
508
+ effect: r.effect === "allow" ? "allow" : "deny",
509
+ ...r.condition ? { condition: String(r.condition) } : {}
510
+ } : void 0;
511
+ return { allowed: d?.allowed === true, reason: String(d?.reason ?? ""), ...rule ? { rule } : {} };
438
512
  }
439
513
 
440
514
  // src/system.ts
@@ -914,6 +988,9 @@ function createSystem(given = {}) {
914
988
  const clientSecret = given.clientSecret ?? env("CAUTHY_CLIENT_SECRET") ?? "";
915
989
  const envKey = env("CAUTHY_SYSTEM_KEY");
916
990
  const key = given.key ?? (envKey ? { privateKey: envKey, keyId: env("CAUTHY_SYSTEM_KEY_ID") ?? "" } : void 0);
991
+ const ownSystem = given.system ?? env("CAUTHY_SYSTEM") ?? "";
992
+ const acceptInviteUrl = given.acceptInviteUrl ?? env("CAUTHY_ACCEPT_INVITE_URL") ?? "";
993
+ const inviteHeaders = /^https?:\/\//.test(acceptInviteUrl) ? { "x-cauthy-client-id": clientId, "x-cauthy-link-to": acceptInviteUrl } : {};
917
994
  const doFetch = (...args) => (given.fetch ?? fetch)(...args);
918
995
  const tokenEndpoint = `${issuer}/v1/oauth/token`;
919
996
  function configured() {
@@ -956,10 +1033,10 @@ function createSystem(given = {}) {
956
1033
  asking = void 0;
957
1034
  });
958
1035
  }
959
- async function call(method, path, body) {
1036
+ async function call(method, path, body, extra = {}) {
960
1037
  const send = async () => doFetch(`${issuer}${path}`, {
961
1038
  method,
962
- headers: { authorization: `Bearer ${await token()}`, ...body === void 0 ? {} : { "content-type": "application/json" } },
1039
+ headers: { authorization: `Bearer ${await token()}`, ...extra, ...body === void 0 ? {} : { "content-type": "application/json" } },
963
1040
  body: body === void 0 ? void 0 : JSON.stringify(body)
964
1041
  });
965
1042
  let res = await send();
@@ -970,7 +1047,7 @@ function createSystem(given = {}) {
970
1047
  if (!res.ok) await fail2(res, `${method} ${path}`);
971
1048
  return res.status === 204 ? void 0 : res.json();
972
1049
  }
973
- const data = async (method, path, body) => (await call(method, path, body))?.data;
1050
+ const data = async (method, path, body, extra) => (await call(method, path, body, extra))?.data;
974
1051
  const id = encodeURIComponent;
975
1052
  return {
976
1053
  token,
@@ -1007,7 +1084,8 @@ function createSystem(given = {}) {
1007
1084
  return toMember(await data(
1008
1085
  "POST",
1009
1086
  `/v1/organizations/${id(orgId)}/members`,
1010
- { role: m.role, user_id: m.userId, email: m.email, name: m.name, locale: m.locale, client_id: clientId }
1087
+ { role: m.role, user_id: m.userId, email: m.email, name: m.name, locale: m.locale, client_id: clientId },
1088
+ inviteHeaders
1011
1089
  ));
1012
1090
  },
1013
1091
  async setRole(orgId, userId, role) {
@@ -1018,7 +1096,7 @@ function createSystem(given = {}) {
1018
1096
  }
1019
1097
  },
1020
1098
  async invite(person) {
1021
- const u = await data("POST", "/v1/users", { ...person, client_id: clientId });
1099
+ const u = await data("POST", "/v1/users", { ...person, client_id: clientId }, inviteHeaders);
1022
1100
  return { id: u.id, email: u.email };
1023
1101
  },
1024
1102
  theme: {
@@ -1031,6 +1109,19 @@ function createSystem(given = {}) {
1031
1109
  return (await data("GET", `/v1/systems/${id(system)}/policy`)).roles ?? [];
1032
1110
  }
1033
1111
  },
1112
+ catalog: {
1113
+ async get(system) {
1114
+ return (await data("GET", `/v1/systems/${id(system)}/catalog`)).resources ?? [];
1115
+ },
1116
+ async set(system, resources) {
1117
+ return (await data("PUT", `/v1/systems/${id(system)}/catalog`, { resources })).resources ?? [];
1118
+ }
1119
+ },
1120
+ async check(q) {
1121
+ const system = q.system ?? ownSystem;
1122
+ if (system === "") throw new CauthyConfigError("name the system to check in (system, or CAUTHY_SYSTEM)");
1123
+ return checkResultOf(await data("POST", `/v1/systems/${id(system)}/check`, { organization_id: q.organizationId, user_id: q.userId, ...checkWire(q) }));
1124
+ },
1034
1125
  async publicJwks() {
1035
1126
  if (!key) return { keys: [] };
1036
1127
  const { kty, n, e } = asJwk(key) ?? await (0, import_jose.exportJWK)(await signingKey());
@@ -1194,10 +1285,12 @@ var POLICY_HELP = `cauthy-auth policy --pull [--out <file.ts>] [--system <name>]
1194
1285
  cauthy-auth policy --file <roles.json> [--system <name>] [--push]
1195
1286
 
1196
1287
  --pull writes the catalog Cauthy has as a TypeScript module, so the
1197
- application's roles and permissions are typed from it:
1288
+ application's roles and permissions are typed from it, and, when the system
1289
+ declares its resources, what a check may ask (resources, actions, attributes):
1198
1290
 
1199
- import { can, hasRole } from './policy'
1291
+ import { can, hasRole, policy } from './policy'
1200
1292
  can(organization, 'ledger:write')
1293
+ await policy.check(auth, request, { organization, resource: 'invoices', action: 'approve', attributes: { amount: 500 } })
1201
1294
 
1202
1295
  --pull Read the catalog from Cauthy.
1203
1296
  --out <file> Write the module there. Without it, it is printed.
@@ -1208,13 +1301,19 @@ CAUTHY_TOKEN (a personal access token with organizations:manage).
1208
1301
 
1209
1302
  --file does the opposite, once:
1210
1303
 
1211
- Loads a system's catalog of roles into Cauthy from a file:
1304
+ Loads a system's catalog of roles into Cauthy from a file: a list of roles,
1305
+ or the resources the system declares with them.
1212
1306
 
1213
- { "roles": [ { "role": "accountant", "name": "Accountant", "inherits": ["reader"],
1214
- "permissions": [ { "resource": "ledger", "action": "write" },
1215
- { "kind": "business", "resource": "books.close", "action": "write" } ] } ] }
1307
+ { "catalog": [ { "resource": "invoices", "actions": ["read", "approve"],
1308
+ "attributes": { "amount": "number", "branch": "string" } } ],
1309
+ "roles": [ { "role": "accountant", "name": "Accountant", "inherits": ["reader"],
1310
+ "permissions": [ { "resource": "invoices", "action": "read" },
1311
+ { "resource": "invoices", "action": "approve",
1312
+ "condition": "resource.attrs.amount <= 1000000" } ] } ] }
1216
1313
 
1217
- --file <file> The catalog, as JSON.
1314
+ --file <file> The catalog, as JSON. Without "catalog", the resources
1315
+ Cauthy has stay as they are; with it, they are sent first
1316
+ and the roles are checked against them.
1218
1317
  --system <name> The system it is of. Default: CAUTHY_SYSTEM.
1219
1318
  --push Send it to Cauthy, REPLACING the catalog it has. Without it
1220
1319
  the catalog is only checked and shown.
@@ -1223,7 +1322,7 @@ Use it to load a first catalog. Afterwards the catalog is Cauthy's, edited in
1223
1322
  its dashboard: pushing again overwrites what was edited there.
1224
1323
 
1225
1324
  To push: CAUTHY_ISSUER, CAUTHY_TENANT (the workspace's slug) and CAUTHY_TOKEN
1226
- (a personal access token with organizations:manage).
1325
+ (a personal access token with systems:policy).
1227
1326
  `;
1228
1327
  async function policy(argv) {
1229
1328
  if (argv.includes("--help") || argv.includes("-h")) {
@@ -1249,12 +1348,14 @@ ${POLICY_HELP}`);
1249
1348
  }
1250
1349
  if (pull) {
1251
1350
  const of = system ?? process.env.CAUTHY_SYSTEM ?? fail("name the system with --system or CAUTHY_SYSTEM");
1252
- const read = process.env.CAUTHY_TOKEN ? await pullPolicy({ system: of }) : await createSystem().policy.get(of);
1351
+ const own = process.env.CAUTHY_TOKEN ? void 0 : createSystem();
1352
+ const [read, catalog2] = await Promise.all(own ? [own.policy.get(of), own.catalog.get(of).catch(() => [])] : [pullPolicy({ system: of }), pullCatalog({ system: of }).catch(() => [])]);
1253
1353
  if (read.length === 0) fail(`Cauthy has no catalog of roles for "${of}"`);
1254
- const source = policySource(of, read);
1354
+ const source = policySource(of, { catalog: catalog2, roles: read });
1255
1355
  if (out2) {
1256
1356
  (0, import_node_fs.writeFileSync)(out2, source);
1257
- process.stdout.write(`${read.length} roles written to ${out2}: ${read.map((r) => r.role).join(", ")}
1357
+ const resources = catalog2.length ? ` and ${catalog2.length} resources (${catalog2.map((c) => c.resource).join(", ")})` : "";
1358
+ process.stdout.write(`${read.length} roles${resources} written to ${out2}: ${read.map((r) => r.role).join(", ")}
1258
1359
  `);
1259
1360
  } else {
1260
1361
  process.stdout.write(source);
@@ -1262,20 +1363,23 @@ ${POLICY_HELP}`);
1262
1363
  return;
1263
1364
  }
1264
1365
  if (!file) fail("give the catalog with --file\n\n" + POLICY_HELP);
1265
- let roles;
1366
+ let given;
1367
+ let withCatalog = false;
1266
1368
  try {
1267
1369
  const parsed = JSON.parse((0, import_node_fs.readFileSync)(file, "utf8"));
1268
- const list = Array.isArray(parsed) ? parsed : parsed.roles;
1269
- if (!Array.isArray(list)) throw new Error('no "roles" list');
1270
- roles = list;
1370
+ given = readPolicyFile(parsed);
1371
+ withCatalog = !Array.isArray(parsed) && parsed.catalog !== void 0;
1271
1372
  } catch (err) {
1272
1373
  return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
1273
1374
  }
1375
+ const { roles, catalog } = given;
1274
1376
  const permissions = roles.reduce((n, r) => n + (r.permissions?.length ?? 0), 0);
1377
+ if (withCatalog) process.stdout.write(`${catalog.length} resources: ${catalog.map((c) => c.resource).join(", ")}
1378
+ `);
1275
1379
  process.stdout.write(`${roles.length} roles, ${permissions} permissions: ${roles.map((r) => r.role).join(", ")}
1276
1380
  `);
1277
1381
  if (push) {
1278
- await pushPolicy({ system, roles });
1382
+ await pushPolicy({ system, roles, ...withCatalog ? { catalog } : {} });
1279
1383
  process.stdout.write("Sent to Cauthy: this is now the catalog of the system.\n");
1280
1384
  } else {
1281
1385
  process.stdout.write("Not sent. Add --push to give it to Cauthy.\n");