@authyon/auth 0.2.0-beta.21 → 0.2.0-beta.22

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.
@@ -212,6 +212,17 @@ interface Organization {
212
212
  customFields?: string;
213
213
  /** JSON-encoded metadata visible to members of the organization. */
214
214
  publicMetadata?: string;
215
+ /**
216
+ * `false` for an organization the user belongs to. `true` for one they may
217
+ * switch into from an ancestor without being a member — show it as borrowed
218
+ * rather than owned. Only `organization.list()` fills it.
219
+ *
220
+ * Ignoring the field still leaves you with a switchable organization, which
221
+ * is what it is. Absent on an API older than 2026-10-31.
222
+ */
223
+ inherited?: boolean;
224
+ /** Distance from the root: `0` at a root organization. Indent the switcher by it. */
225
+ depth?: number;
215
226
  }
216
227
  /** POST /auth/tenants — creates an organization owned by the signed-in user. */
217
228
  interface CreateOrganizationInput {
@@ -219,6 +230,16 @@ interface CreateOrganizationInput {
219
230
  slug?: string;
220
231
  description?: string;
221
232
  customFields?: Record<string, unknown>;
233
+ /**
234
+ * Creates the organization underneath an existing one. Omitted creates a
235
+ * root organization, which is all this endpoint could do before the
236
+ * hierarchy existed.
237
+ *
238
+ * The caller has to belong to the parent (or to an organization above it)
239
+ * and hold `subtenants:tree:create` there, and the environment's
240
+ * `maxTenantDepth` has to allow the level — it ships as `1`, roots only.
241
+ */
242
+ parentTenantId?: string;
222
243
  }
223
244
  /** GET /auth/tenants/{organizationId}/members — confirmed against the live API. */
224
245
  interface OrganizationMember {
@@ -517,7 +538,13 @@ interface AuthyonClientOptions {
517
538
  /** Publishable environment key (`pk_live_...` / `pk_test_...`). */
518
539
  envKey: string;
519
540
  /**
520
- * Originating client IP to forward on every API call as `X-Forwarded-For`.
541
+ * Originating client IP to forward on every API call as
542
+ * `X-Authyon-Client-Ip` (and `X-Forwarded-For`, for your own proxies).
543
+ *
544
+ * Authyon honours it only from a trusted BFF — a socket peer in the API's
545
+ * `TRUSTED_BFF_IPS`, or a request carrying `TRUSTED_BFF_SECRET`, which this
546
+ * SDK does not send. Never set it in the browser: the connection already
547
+ * carries the address.
521
548
  * Browsers cannot reliably discover their public IP by themselves; set this
522
549
  * only when your application receives it from trusted infrastructure.
523
550
  */
@@ -583,7 +610,21 @@ declare class AuthyonClient {
583
610
  */
584
611
  private hydrateUser;
585
612
  private request;
586
- /** POST /auth/register — creates a new user (rate-limited to 20/hour per IP). */
613
+ /**
614
+ * POST /auth/register — creates a new user (rate-limited to 20/hour per IP).
615
+ *
616
+ * An environment may close public sign-up, and then this route answers as
617
+ * though it were not mapped: **404 with no body**, so the error carries
618
+ * status 404 and the code `unknown`. The SDK does not relabel it, because a
619
+ * wrong `baseUrl` looks exactly the same from here and guessing would send
620
+ * you after the wrong bug — check the environment's setting in the console
621
+ * and the URL you configured. Accounts in a closed environment are created
622
+ * by your backend instead, through `environment.users.create()` in
623
+ * `@authyon/server`, which already needs the `users:write` scope.
624
+ *
625
+ * It gates creation only. Everyone who already has an account signs in as
626
+ * before, and magic link and social sign-up keep their own switches.
627
+ */
587
628
  register(params: RegisterInput): Promise<{
588
629
  id: string;
589
630
  }>;
@@ -672,7 +713,15 @@ declare class AuthyonClient {
672
713
  confirmPasswordReset: (token: string, newPassword: string) => Promise<PasswordResetCompleted>;
673
714
  };
674
715
  readonly organization: {
675
- /** GET /auth/tenants — filterable list of organization memberships. */
716
+ /**
717
+ * GET /auth/tenants — filterable list of organization memberships.
718
+ *
719
+ * Also lists the organizations below them that the user may switch into
720
+ * without being a member, marked `inherited` — only the ones that actually
721
+ * carry `subtenants:tree:switch`, so the switcher never offers an
722
+ * organization the switch would then refuse. `depth` indents the list into
723
+ * the tree it describes.
724
+ */
676
725
  list: (params?: {
677
726
  search?: string;
678
727
  } & PaginationOptions) => Promise<Organization[]>;
@@ -680,16 +729,41 @@ declare class AuthyonClient {
680
729
  * POST /auth/tenants — creates an organization owned by the signed-in
681
730
  * user (only available when self-service organization creation is
682
731
  * enabled for the environment).
732
+ *
733
+ * `parentTenantId` nests it under one the user already belongs to, which
734
+ * needs `subtenants:tree:create` there. The creator lands as the effective
735
+ * owner either way, with `tenants:settings:manage`,
736
+ * `tenants:members:invite` and `tenants:members:remove` granted directly
737
+ * inside the new organization.
683
738
  */
684
739
  create: (params?: CreateOrganizationInput) => Promise<Organization>;
685
740
  /** GET /auth/tenants/{organizationId} — fetch one of the user's organizations by id. */
686
741
  get: (organizationId: string) => Promise<Organization>;
687
742
  /**
688
743
  * PATCH /auth/tenants/{organizationId} — renames the organization.
689
- * Requires the `tenants:manage` custom permission on it.
744
+ *
745
+ * Requires the `tenants:settings:manage` custom permission on it, or
746
+ * `subtenants:tree:manage` on an organization above it. The value was
747
+ * spelled `tenants:manage` before 2026-10-31 — two segments, which the API
748
+ * could not store, so the endpoint refused everyone who had been granted
749
+ * it.
690
750
  */
691
751
  rename: (organizationId: string, name: string) => Promise<Organization>;
692
- /** POST /auth/switch-tenant — issues a fresh token scoped to the new organization. */
752
+ /**
753
+ * POST /auth/switch-tenant — issues a fresh token scoped to the new
754
+ * organization.
755
+ *
756
+ * Membership is enough for one of the user's own organizations. Descending
757
+ * into one below it takes `subtenants:tree:switch` at the organization they
758
+ * belong to — deliberately not the same permission as administering a
759
+ * sub-organization from the outside, since operating AS it is the larger of
760
+ * the two powers. The grants then travel from the node the user belongs to,
761
+ * and the token says so in its `tenant.via`.
762
+ *
763
+ * Refusals: `tenant.outside_scope` (no path down to it),
764
+ * `tenant.ancestor_disabled`, and `tenant.path_stale` when the tree moved
765
+ * under a session — sign-in again rather than retrying.
766
+ */
693
767
  switch: (organizationSlug: string) => Promise<Session>;
694
768
  /** The organization the current session is scoped to, from the cached session — no network call. */
695
769
  current: () => Organization | null;
@@ -918,11 +992,25 @@ declare class AuthyonAbilityBuilder {
918
992
  }
919
993
  /** Creates an ability from Authyon permissions, OAuth scope and optional role rules. */
920
994
  declare function createAuthyonAbility(source?: AuthyonPermissionSource, options?: AuthyonAbilityOptions): AuthyonAbility;
921
- /** Checks an Authyon permission string using the same wildcard rules as an ability. */
995
+ /**
996
+ * Whether a source holds an Authyon permission, by Authyon's own rules: the
997
+ * values are compared segment by segment, `*` stands for any one segment, and
998
+ * two values of different lengths never match — except an all-wildcard grant,
999
+ * which is everything. OAuth scopes count as permissions.
1000
+ *
1001
+ * Not the same question as {@link AuthyonAbility.can}. An ability is CASL, so
1002
+ * the action `manage` there means "every action on this subject", which is a
1003
+ * useful thing to author by hand and a wrong thing to infer from a permission:
1004
+ * `subtenants:tree:manage` and `subtenants:tree:switch` are deliberately
1005
+ * separate grants on the API side — administering a sub-tenant from above and
1006
+ * operating as it are different powers — and reading the first as the second
1007
+ * would have this answer yes to a call the API then refuses. Ask this one
1008
+ * about anything Authyon will be enforcing.
1009
+ */
922
1010
  declare function hasPermission(source: AuthyonPermissionSource | readonly string[], requiredPermission: string): boolean;
923
1011
  /**
924
- * Checks a permission group using Authyon's standard permission and wildcard rules.
925
- * Empty or omitted groups do not add authorization requirements.
1012
+ * Checks a permission group with {@link hasPermission}, so the same rules as
1013
+ * the API. Empty or omitted groups do not add authorization requirements.
926
1014
  */
927
1015
  declare function hasPermissionGroup(source: AuthyonPermissionSource | readonly string[], group: PermissionGroup): boolean;
928
1016
  /** Converts Authyon's `subject:action` permission strings to authorization rules. */
@@ -212,6 +212,17 @@ interface Organization {
212
212
  customFields?: string;
213
213
  /** JSON-encoded metadata visible to members of the organization. */
214
214
  publicMetadata?: string;
215
+ /**
216
+ * `false` for an organization the user belongs to. `true` for one they may
217
+ * switch into from an ancestor without being a member — show it as borrowed
218
+ * rather than owned. Only `organization.list()` fills it.
219
+ *
220
+ * Ignoring the field still leaves you with a switchable organization, which
221
+ * is what it is. Absent on an API older than 2026-10-31.
222
+ */
223
+ inherited?: boolean;
224
+ /** Distance from the root: `0` at a root organization. Indent the switcher by it. */
225
+ depth?: number;
215
226
  }
216
227
  /** POST /auth/tenants — creates an organization owned by the signed-in user. */
217
228
  interface CreateOrganizationInput {
@@ -219,6 +230,16 @@ interface CreateOrganizationInput {
219
230
  slug?: string;
220
231
  description?: string;
221
232
  customFields?: Record<string, unknown>;
233
+ /**
234
+ * Creates the organization underneath an existing one. Omitted creates a
235
+ * root organization, which is all this endpoint could do before the
236
+ * hierarchy existed.
237
+ *
238
+ * The caller has to belong to the parent (or to an organization above it)
239
+ * and hold `subtenants:tree:create` there, and the environment's
240
+ * `maxTenantDepth` has to allow the level — it ships as `1`, roots only.
241
+ */
242
+ parentTenantId?: string;
222
243
  }
223
244
  /** GET /auth/tenants/{organizationId}/members — confirmed against the live API. */
224
245
  interface OrganizationMember {
@@ -517,7 +538,13 @@ interface AuthyonClientOptions {
517
538
  /** Publishable environment key (`pk_live_...` / `pk_test_...`). */
518
539
  envKey: string;
519
540
  /**
520
- * Originating client IP to forward on every API call as `X-Forwarded-For`.
541
+ * Originating client IP to forward on every API call as
542
+ * `X-Authyon-Client-Ip` (and `X-Forwarded-For`, for your own proxies).
543
+ *
544
+ * Authyon honours it only from a trusted BFF — a socket peer in the API's
545
+ * `TRUSTED_BFF_IPS`, or a request carrying `TRUSTED_BFF_SECRET`, which this
546
+ * SDK does not send. Never set it in the browser: the connection already
547
+ * carries the address.
521
548
  * Browsers cannot reliably discover their public IP by themselves; set this
522
549
  * only when your application receives it from trusted infrastructure.
523
550
  */
@@ -583,7 +610,21 @@ declare class AuthyonClient {
583
610
  */
584
611
  private hydrateUser;
585
612
  private request;
586
- /** POST /auth/register — creates a new user (rate-limited to 20/hour per IP). */
613
+ /**
614
+ * POST /auth/register — creates a new user (rate-limited to 20/hour per IP).
615
+ *
616
+ * An environment may close public sign-up, and then this route answers as
617
+ * though it were not mapped: **404 with no body**, so the error carries
618
+ * status 404 and the code `unknown`. The SDK does not relabel it, because a
619
+ * wrong `baseUrl` looks exactly the same from here and guessing would send
620
+ * you after the wrong bug — check the environment's setting in the console
621
+ * and the URL you configured. Accounts in a closed environment are created
622
+ * by your backend instead, through `environment.users.create()` in
623
+ * `@authyon/server`, which already needs the `users:write` scope.
624
+ *
625
+ * It gates creation only. Everyone who already has an account signs in as
626
+ * before, and magic link and social sign-up keep their own switches.
627
+ */
587
628
  register(params: RegisterInput): Promise<{
588
629
  id: string;
589
630
  }>;
@@ -672,7 +713,15 @@ declare class AuthyonClient {
672
713
  confirmPasswordReset: (token: string, newPassword: string) => Promise<PasswordResetCompleted>;
673
714
  };
674
715
  readonly organization: {
675
- /** GET /auth/tenants — filterable list of organization memberships. */
716
+ /**
717
+ * GET /auth/tenants — filterable list of organization memberships.
718
+ *
719
+ * Also lists the organizations below them that the user may switch into
720
+ * without being a member, marked `inherited` — only the ones that actually
721
+ * carry `subtenants:tree:switch`, so the switcher never offers an
722
+ * organization the switch would then refuse. `depth` indents the list into
723
+ * the tree it describes.
724
+ */
676
725
  list: (params?: {
677
726
  search?: string;
678
727
  } & PaginationOptions) => Promise<Organization[]>;
@@ -680,16 +729,41 @@ declare class AuthyonClient {
680
729
  * POST /auth/tenants — creates an organization owned by the signed-in
681
730
  * user (only available when self-service organization creation is
682
731
  * enabled for the environment).
732
+ *
733
+ * `parentTenantId` nests it under one the user already belongs to, which
734
+ * needs `subtenants:tree:create` there. The creator lands as the effective
735
+ * owner either way, with `tenants:settings:manage`,
736
+ * `tenants:members:invite` and `tenants:members:remove` granted directly
737
+ * inside the new organization.
683
738
  */
684
739
  create: (params?: CreateOrganizationInput) => Promise<Organization>;
685
740
  /** GET /auth/tenants/{organizationId} — fetch one of the user's organizations by id. */
686
741
  get: (organizationId: string) => Promise<Organization>;
687
742
  /**
688
743
  * PATCH /auth/tenants/{organizationId} — renames the organization.
689
- * Requires the `tenants:manage` custom permission on it.
744
+ *
745
+ * Requires the `tenants:settings:manage` custom permission on it, or
746
+ * `subtenants:tree:manage` on an organization above it. The value was
747
+ * spelled `tenants:manage` before 2026-10-31 — two segments, which the API
748
+ * could not store, so the endpoint refused everyone who had been granted
749
+ * it.
690
750
  */
691
751
  rename: (organizationId: string, name: string) => Promise<Organization>;
692
- /** POST /auth/switch-tenant — issues a fresh token scoped to the new organization. */
752
+ /**
753
+ * POST /auth/switch-tenant — issues a fresh token scoped to the new
754
+ * organization.
755
+ *
756
+ * Membership is enough for one of the user's own organizations. Descending
757
+ * into one below it takes `subtenants:tree:switch` at the organization they
758
+ * belong to — deliberately not the same permission as administering a
759
+ * sub-organization from the outside, since operating AS it is the larger of
760
+ * the two powers. The grants then travel from the node the user belongs to,
761
+ * and the token says so in its `tenant.via`.
762
+ *
763
+ * Refusals: `tenant.outside_scope` (no path down to it),
764
+ * `tenant.ancestor_disabled`, and `tenant.path_stale` when the tree moved
765
+ * under a session — sign-in again rather than retrying.
766
+ */
693
767
  switch: (organizationSlug: string) => Promise<Session>;
694
768
  /** The organization the current session is scoped to, from the cached session — no network call. */
695
769
  current: () => Organization | null;
@@ -918,11 +992,25 @@ declare class AuthyonAbilityBuilder {
918
992
  }
919
993
  /** Creates an ability from Authyon permissions, OAuth scope and optional role rules. */
920
994
  declare function createAuthyonAbility(source?: AuthyonPermissionSource, options?: AuthyonAbilityOptions): AuthyonAbility;
921
- /** Checks an Authyon permission string using the same wildcard rules as an ability. */
995
+ /**
996
+ * Whether a source holds an Authyon permission, by Authyon's own rules: the
997
+ * values are compared segment by segment, `*` stands for any one segment, and
998
+ * two values of different lengths never match — except an all-wildcard grant,
999
+ * which is everything. OAuth scopes count as permissions.
1000
+ *
1001
+ * Not the same question as {@link AuthyonAbility.can}. An ability is CASL, so
1002
+ * the action `manage` there means "every action on this subject", which is a
1003
+ * useful thing to author by hand and a wrong thing to infer from a permission:
1004
+ * `subtenants:tree:manage` and `subtenants:tree:switch` are deliberately
1005
+ * separate grants on the API side — administering a sub-tenant from above and
1006
+ * operating as it are different powers — and reading the first as the second
1007
+ * would have this answer yes to a call the API then refuses. Ask this one
1008
+ * about anything Authyon will be enforcing.
1009
+ */
922
1010
  declare function hasPermission(source: AuthyonPermissionSource | readonly string[], requiredPermission: string): boolean;
923
1011
  /**
924
- * Checks a permission group using Authyon's standard permission and wildcard rules.
925
- * Empty or omitted groups do not add authorization requirements.
1012
+ * Checks a permission group with {@link hasPermission}, so the same rules as
1013
+ * the API. Empty or omitted groups do not add authorization requirements.
926
1014
  */
927
1015
  declare function hasPermissionGroup(source: AuthyonPermissionSource | readonly string[], group: PermissionGroup): boolean;
928
1016
  /** Converts Authyon's `subject:action` permission strings to authorization rules. */
@@ -194,12 +194,29 @@ function createAuthyonAbility(source = {}, options = {}) {
194
194
  return new AuthyonAbility(createAuthyonRules(source, options), options.detectSubjectType);
195
195
  }
196
196
  function hasPermission(source, requiredPermission) {
197
- const requirement = permissionToRule(requiredPermission);
198
- if (!requirement || typeof requirement.action !== "string" || typeof requirement.subject !== "string") {
199
- return false;
200
- }
197
+ const required = requiredPermission?.trim() ?? "";
198
+ if (!isWellFormedPermission(required)) return false;
201
199
  const permissionSource = isPermissionList(source) ? { permissions: source } : source;
202
- return createAuthyonAbility(permissionSource).can(requirement.action, requirement.subject);
200
+ const granted = [
201
+ ...permissionSource.permissions ?? [],
202
+ ...permissionSource.scope?.split(/\s+/).filter(Boolean) ?? []
203
+ ];
204
+ return granted.some((permission) => grants(permission.trim(), required));
205
+ }
206
+ function grants(granted, required) {
207
+ if (!isWellFormedPermission(granted)) return false;
208
+ if (isFullWildcard(granted)) return true;
209
+ const grantedSegments = granted.split(":");
210
+ const requiredSegments = required.split(":");
211
+ if (grantedSegments.length !== requiredSegments.length) return false;
212
+ return grantedSegments.every(
213
+ (segment, index) => segment === "*" || segment === requiredSegments[index]
214
+ );
215
+ }
216
+ function isWellFormedPermission(permission) {
217
+ if (!permission) return false;
218
+ const segments = permission.split(":");
219
+ return segments.length >= 2 && segments.every((segment) => segment.length > 0);
203
220
  }
204
221
  function hasPermissionGroup(source, group) {
205
222
  const hasAll = (group.allOf ?? []).every((permission) => hasPermission(source, permission));
@@ -214,13 +231,23 @@ function createAuthyonRules(source = {}, options = {}) {
214
231
  ...source.permissions ?? [],
215
232
  ...source.scope?.split(/\s+/).filter(Boolean) ?? []
216
233
  ]);
217
- const rules = [...permissions].map(permissionToRule).filter(isAbilityRule);
234
+ const rules = [...permissions].map(grantToRule).filter(isAbilityRule);
218
235
  for (const role of /* @__PURE__ */ new Set([...source.roles ?? [], ...options.roles ?? []])) {
219
236
  rules.push(...(options.roleRules?.[role] ?? []).map(cloneRule));
220
237
  }
221
238
  rules.push(...(options.rules ?? []).map(cloneRule));
222
239
  return rules;
223
240
  }
241
+ function grantToRule(permission) {
242
+ const normalized = permission.trim();
243
+ if (normalized && isFullWildcard(normalized)) {
244
+ return { action: "manage", subject: "all" };
245
+ }
246
+ return permissionToRule(normalized);
247
+ }
248
+ function isFullWildcard(permission) {
249
+ return permission.split(":").every((segment) => segment === "*");
250
+ }
224
251
  function permissionToRule(permission) {
225
252
  const normalized = permission.trim();
226
253
  if (!normalized) return null;
package/dist/index.cjs CHANGED
@@ -29,6 +29,8 @@ __export(index_exports, {
29
29
  ErrorCodes: () => ErrorCodes,
30
30
  FetchHttpAdapter: () => FetchHttpAdapter,
31
31
  LoggingHttpAdapter: () => LoggingHttpAdapter,
32
+ SubtenantPermissions: () => SubtenantPermissions,
33
+ TenantPermissions: () => TenantPermissions,
32
34
  createAuthyonAbility: () => createAuthyonAbility,
33
35
  createAuthyonRules: () => createAuthyonRules,
34
36
  createClient: () => createClient,
@@ -474,9 +476,11 @@ var JsonHttpClient = class {
474
476
  };
475
477
 
476
478
  // ../../internal/core/http/clientIp.ts
479
+ var CLIENT_IP_HEADER = "X-Authyon-Client-Ip";
477
480
  function clientIpHeaders(context) {
478
481
  if (!context?.clientIp) return {};
479
- return { "X-Forwarded-For": normalizeClientIp(context.clientIp) };
482
+ const ip = normalizeClientIp(context.clientIp);
483
+ return { "X-Forwarded-For": ip, [CLIENT_IP_HEADER]: ip };
480
484
  }
481
485
  function normalizeClientIp(value) {
482
486
  let ip = value.trim();
@@ -605,26 +609,59 @@ var AuthyonClient = class {
605
609
  };
606
610
  // ── Organization ─────────────────────────────────────────────────────────
607
611
  this.organization = {
608
- /** GET /auth/tenants — filterable list of organization memberships. */
612
+ /**
613
+ * GET /auth/tenants — filterable list of organization memberships.
614
+ *
615
+ * Also lists the organizations below them that the user may switch into
616
+ * without being a member, marked `inherited` — only the ones that actually
617
+ * carry `subtenants:tree:switch`, so the switcher never offers an
618
+ * organization the switch would then refuse. `depth` indents the list into
619
+ * the tree it describes.
620
+ */
609
621
  list: (params = {}) => this.request(appendQuery("/auth/tenants", params), { bearer: true }),
610
622
  /**
611
623
  * POST /auth/tenants — creates an organization owned by the signed-in
612
624
  * user (only available when self-service organization creation is
613
625
  * enabled for the environment).
626
+ *
627
+ * `parentTenantId` nests it under one the user already belongs to, which
628
+ * needs `subtenants:tree:create` there. The creator lands as the effective
629
+ * owner either way, with `tenants:settings:manage`,
630
+ * `tenants:members:invite` and `tenants:members:remove` granted directly
631
+ * inside the new organization.
614
632
  */
615
633
  create: (params = {}) => this.request("/auth/tenants", { method: "POST", bearer: true, body: params }),
616
634
  /** GET /auth/tenants/{organizationId} — fetch one of the user's organizations by id. */
617
635
  get: (organizationId) => this.request(`/auth/tenants/${encodeURIComponent(organizationId)}`, { bearer: true }),
618
636
  /**
619
637
  * PATCH /auth/tenants/{organizationId} — renames the organization.
620
- * Requires the `tenants:manage` custom permission on it.
638
+ *
639
+ * Requires the `tenants:settings:manage` custom permission on it, or
640
+ * `subtenants:tree:manage` on an organization above it. The value was
641
+ * spelled `tenants:manage` before 2026-10-31 — two segments, which the API
642
+ * could not store, so the endpoint refused everyone who had been granted
643
+ * it.
621
644
  */
622
645
  rename: (organizationId, name) => this.request(`/auth/tenants/${encodeURIComponent(organizationId)}`, {
623
646
  method: "PATCH",
624
647
  bearer: true,
625
648
  body: { name }
626
649
  }),
627
- /** POST /auth/switch-tenant — issues a fresh token scoped to the new organization. */
650
+ /**
651
+ * POST /auth/switch-tenant — issues a fresh token scoped to the new
652
+ * organization.
653
+ *
654
+ * Membership is enough for one of the user's own organizations. Descending
655
+ * into one below it takes `subtenants:tree:switch` at the organization they
656
+ * belong to — deliberately not the same permission as administering a
657
+ * sub-organization from the outside, since operating AS it is the larger of
658
+ * the two powers. The grants then travel from the node the user belongs to,
659
+ * and the token says so in its `tenant.via`.
660
+ *
661
+ * Refusals: `tenant.outside_scope` (no path down to it),
662
+ * `tenant.ancestor_disabled`, and `tenant.path_stale` when the tree moved
663
+ * under a session — sign-in again rather than retrying.
664
+ */
628
665
  switch: (organizationSlug) => this.request("/auth/switch-tenant", {
629
666
  method: "POST",
630
667
  bearer: true,
@@ -938,7 +975,21 @@ var AuthyonClient = class {
938
975
  return this.http.parse(response);
939
976
  }
940
977
  // ── Auth flows ───────────────────────────────────────────────────────────
941
- /** POST /auth/register — creates a new user (rate-limited to 20/hour per IP). */
978
+ /**
979
+ * POST /auth/register — creates a new user (rate-limited to 20/hour per IP).
980
+ *
981
+ * An environment may close public sign-up, and then this route answers as
982
+ * though it were not mapped: **404 with no body**, so the error carries
983
+ * status 404 and the code `unknown`. The SDK does not relabel it, because a
984
+ * wrong `baseUrl` looks exactly the same from here and guessing would send
985
+ * you after the wrong bug — check the environment's setting in the console
986
+ * and the URL you configured. Accounts in a closed environment are created
987
+ * by your backend instead, through `environment.users.create()` in
988
+ * `@authyon/server`, which already needs the `users:write` scope.
989
+ *
990
+ * It gates creation only. Everyone who already has an account signs in as
991
+ * before, and magic link and social sign-up keep their own switches.
992
+ */
942
993
  async register(params) {
943
994
  return this.request("/auth/register", { method: "POST", body: params });
944
995
  }
@@ -1295,12 +1346,29 @@ function createAuthyonAbility(source = {}, options = {}) {
1295
1346
  return new AuthyonAbility(createAuthyonRules(source, options), options.detectSubjectType);
1296
1347
  }
1297
1348
  function hasPermission(source, requiredPermission) {
1298
- const requirement = permissionToRule(requiredPermission);
1299
- if (!requirement || typeof requirement.action !== "string" || typeof requirement.subject !== "string") {
1300
- return false;
1301
- }
1349
+ const required = requiredPermission?.trim() ?? "";
1350
+ if (!isWellFormedPermission(required)) return false;
1302
1351
  const permissionSource = isPermissionList(source) ? { permissions: source } : source;
1303
- return createAuthyonAbility(permissionSource).can(requirement.action, requirement.subject);
1352
+ const granted = [
1353
+ ...permissionSource.permissions ?? [],
1354
+ ...permissionSource.scope?.split(/\s+/).filter(Boolean) ?? []
1355
+ ];
1356
+ return granted.some((permission) => grants(permission.trim(), required));
1357
+ }
1358
+ function grants(granted, required) {
1359
+ if (!isWellFormedPermission(granted)) return false;
1360
+ if (isFullWildcard(granted)) return true;
1361
+ const grantedSegments = granted.split(":");
1362
+ const requiredSegments = required.split(":");
1363
+ if (grantedSegments.length !== requiredSegments.length) return false;
1364
+ return grantedSegments.every(
1365
+ (segment, index) => segment === "*" || segment === requiredSegments[index]
1366
+ );
1367
+ }
1368
+ function isWellFormedPermission(permission) {
1369
+ if (!permission) return false;
1370
+ const segments = permission.split(":");
1371
+ return segments.length >= 2 && segments.every((segment) => segment.length > 0);
1304
1372
  }
1305
1373
  function hasPermissionGroup(source, group) {
1306
1374
  const hasAll = (group.allOf ?? []).every((permission) => hasPermission(source, permission));
@@ -1315,13 +1383,23 @@ function createAuthyonRules(source = {}, options = {}) {
1315
1383
  ...source.permissions ?? [],
1316
1384
  ...source.scope?.split(/\s+/).filter(Boolean) ?? []
1317
1385
  ]);
1318
- const rules = [...permissions].map(permissionToRule).filter(isAbilityRule);
1386
+ const rules = [...permissions].map(grantToRule).filter(isAbilityRule);
1319
1387
  for (const role of /* @__PURE__ */ new Set([...source.roles ?? [], ...options.roles ?? []])) {
1320
1388
  rules.push(...(options.roleRules?.[role] ?? []).map(cloneRule));
1321
1389
  }
1322
1390
  rules.push(...(options.rules ?? []).map(cloneRule));
1323
1391
  return rules;
1324
1392
  }
1393
+ function grantToRule(permission) {
1394
+ const normalized = permission.trim();
1395
+ if (normalized && isFullWildcard(normalized)) {
1396
+ return { action: "manage", subject: "all" };
1397
+ }
1398
+ return permissionToRule(normalized);
1399
+ }
1400
+ function isFullWildcard(permission) {
1401
+ return permission.split(":").every((segment) => segment === "*");
1402
+ }
1325
1403
  function permissionToRule(permission) {
1326
1404
  const normalized = permission.trim();
1327
1405
  if (!normalized) return null;
@@ -1418,6 +1496,32 @@ function isRecord(value) {
1418
1496
  function isAbilityRule(value) {
1419
1497
  return value !== null;
1420
1498
  }
1499
+
1500
+ // ../../internal/core/authorization/tenantPermissions.ts
1501
+ var TenantPermissions = {
1502
+ /** Rename the tenant and change its settings. */
1503
+ SettingsManage: "tenants:settings:manage",
1504
+ /** Invite an address to the tenant. */
1505
+ MembersInvite: "tenants:members:invite",
1506
+ /** Remove a member from the tenant. */
1507
+ MembersRemove: "tenants:members:remove"
1508
+ };
1509
+ var SubtenantPermissions = {
1510
+ /** Read any tenant below one you belong to. */
1511
+ TreeRead: "subtenants:tree:read",
1512
+ /** Rename a tenant below yours, and add or remove its members. */
1513
+ TreeManage: "subtenants:tree:manage",
1514
+ /** Create a tenant underneath one you belong to. */
1515
+ TreeCreate: "subtenants:tree:create",
1516
+ /**
1517
+ * Make a tenant below yours the ACTIVE tenant of the session — acting inside
1518
+ * it rather than on it from outside. Deliberately not the same permission as
1519
+ * {@link SubtenantPermissions.TreeManage}: administering a sub-tenant through
1520
+ * the management endpoints and operating as that sub-tenant are different
1521
+ * powers, and granting the first should not hand over the second.
1522
+ */
1523
+ TreeSwitch: "subtenants:tree:switch"
1524
+ };
1421
1525
  // Annotate the CommonJS export names for ESM import in node:
1422
1526
  0 && (module.exports = {
1423
1527
  AuthyonAbility,
@@ -1429,6 +1533,8 @@ function isAbilityRule(value) {
1429
1533
  ErrorCodes,
1430
1534
  FetchHttpAdapter,
1431
1535
  LoggingHttpAdapter,
1536
+ SubtenantPermissions,
1537
+ TenantPermissions,
1432
1538
  createAuthyonAbility,
1433
1539
  createAuthyonRules,
1434
1540
  createClient,
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as TokenStorage, H as HttpAdapter, a as HttpLoggerOptions, A as AuthyonClient } from './ability-BTcayvkM.cjs';
2
- export { b as AbilityConditions, c as AbilityEvent, d as AbilityListener, e as AbilityRule, f as AbilitySubject, g as AcceptOrganizationInviteInput, h as Activity, i as Approval, j as ApprovalAssurance, k as ApprovalPayload, l as ApprovalStatus, m as ApprovalWebAuthnOptions, n as AuthEvent, o as AuthState, p as AuthStateListener, q as AuthenticatorSetup, r as AuthyonAbility, s as AuthyonAbilityBuilder, t as AuthyonAbilityOptions, u as AuthyonClientOptions, v as AuthyonPermissionSource, w as AuthyonSessionController, C as ConfirmApprovalInput, x as CreateOrganizationInput, F as FetchHttpAdapter, y as HttpAdapterRequest, z as HttpLogEvent, B as HttpLogger, I as IntrospectResult, D as InviteMemberInput, L as LoggingHttpAdapter, E as LoginInput, G as LoginResult, O as Organization, J as OrganizationInvite, K as OrganizationInviteAccepted, M as OrganizationInviteInput, N as OrganizationInviteIssued, P as OrganizationInvitePreview, Q as OrganizationInviteStatus, R as OrganizationMember, S as Paged, U as PaginationOptions, V as PasswordResetCompleted, W as PasswordResetPreview, X as PasswordResetRequestOptions, Y as PermissionGroup, Z as RegisterInput, _ as Role, $ as SecurityMethods, a0 as Session, a1 as SessionControllerOptions, a2 as SessionInfo, a3 as SessionSnapshot, a4 as SessionSnapshotListener, a5 as SessionStatus, a6 as SsoProvider, a7 as SubjectApprovals, a8 as SubjectSecurity, a9 as TwoFactorChallenge, aa as TwoFactorMethod, ab as TwoFactorStatus, ac as User, ad as ValidateResult, ae as VerifyTwoFactorInput, af as WebAuthnAssertion, ag as WebAuthnCeremonyStart, ah as WebAuthnCredential, ai as createAuthyonAbility, aj as createAuthyonRules, ak as createClient, al as hasPermission, am as hasPermissionGroup } from './ability-BTcayvkM.cjs';
1
+ import { T as TokenStorage, H as HttpAdapter, a as HttpLoggerOptions, A as AuthyonClient } from './ability-DjEMCVjx.cjs';
2
+ export { b as AbilityConditions, c as AbilityEvent, d as AbilityListener, e as AbilityRule, f as AbilitySubject, g as AcceptOrganizationInviteInput, h as Activity, i as Approval, j as ApprovalAssurance, k as ApprovalPayload, l as ApprovalStatus, m as ApprovalWebAuthnOptions, n as AuthEvent, o as AuthState, p as AuthStateListener, q as AuthenticatorSetup, r as AuthyonAbility, s as AuthyonAbilityBuilder, t as AuthyonAbilityOptions, u as AuthyonClientOptions, v as AuthyonPermissionSource, w as AuthyonSessionController, C as ConfirmApprovalInput, x as CreateOrganizationInput, F as FetchHttpAdapter, y as HttpAdapterRequest, z as HttpLogEvent, B as HttpLogger, I as IntrospectResult, D as InviteMemberInput, L as LoggingHttpAdapter, E as LoginInput, G as LoginResult, O as Organization, J as OrganizationInvite, K as OrganizationInviteAccepted, M as OrganizationInviteInput, N as OrganizationInviteIssued, P as OrganizationInvitePreview, Q as OrganizationInviteStatus, R as OrganizationMember, S as Paged, U as PaginationOptions, V as PasswordResetCompleted, W as PasswordResetPreview, X as PasswordResetRequestOptions, Y as PermissionGroup, Z as RegisterInput, _ as Role, $ as SecurityMethods, a0 as Session, a1 as SessionControllerOptions, a2 as SessionInfo, a3 as SessionSnapshot, a4 as SessionSnapshotListener, a5 as SessionStatus, a6 as SsoProvider, a7 as SubjectApprovals, a8 as SubjectSecurity, a9 as TwoFactorChallenge, aa as TwoFactorMethod, ab as TwoFactorStatus, ac as User, ad as ValidateResult, ae as VerifyTwoFactorInput, af as WebAuthnAssertion, ag as WebAuthnCeremonyStart, ah as WebAuthnCredential, ai as createAuthyonAbility, aj as createAuthyonRules, ak as createClient, al as hasPermission, am as hasPermissionGroup } from './ability-DjEMCVjx.cjs';
3
3
 
4
4
  declare const ErrorCodes: {
5
5
  readonly Unknown: "unknown";
@@ -99,4 +99,54 @@ declare function createMemoryStorage(): TokenStorage;
99
99
  declare function createLocalStorage(key?: string): TokenStorage;
100
100
  declare function createDefaultStorage(): TokenStorage;
101
101
 
102
- export { AuthyonClient, AuthyonClientBuilder, AuthyonError, type AuthyonErrorAction, type AuthyonErrorBody, type AuthyonErrorCategory, type AuthyonErrorInterpretation, ErrorCodes, HttpAdapter, HttpLoggerOptions, type KnownErrorCode, TokenStorage, createDefaultStorage, createLocalStorage, createMemoryStorage };
102
+ /**
103
+ * The permission values the tenant surface checks, as constants.
104
+ *
105
+ * They are strings on the wire and nothing forces you through here — the point
106
+ * is that a rename shows up as a type error instead of as a 403 nobody can
107
+ * explain. `tenants:settings:manage` is exactly that case: it used to be
108
+ * written `tenants:manage`, a two-segment value the API could not even store,
109
+ * so the endpoint requiring it refused every caller.
110
+ */
111
+ declare const TenantPermissions: {
112
+ /** Rename the tenant and change its settings. */
113
+ readonly SettingsManage: "tenants:settings:manage";
114
+ /** Invite an address to the tenant. */
115
+ readonly MembersInvite: "tenants:members:invite";
116
+ /** Remove a member from the tenant. */
117
+ readonly MembersRemove: "tenants:members:remove";
118
+ };
119
+ /**
120
+ * What lets a member of one tenant reach the tenants beneath it.
121
+ *
122
+ * A separate domain, not a suffix on `tenants:…`, and the reason is worth
123
+ * knowing before you grant one: permissions match per segment, so naming these
124
+ * `tenants:tree:manage` would have handed every role already holding
125
+ * `tenants:*:*` power over the whole subtree the day the hierarchy shipped — a
126
+ * widening that appears in no diff. `subtenants:…` is only covered by `*:*:*`,
127
+ * which is explicit superuser.
128
+ *
129
+ * Reaching down is opt-in at every level: holding `tenants:settings:manage` on
130
+ * a parent grants nothing below it, and each of these is granted at the node
131
+ * the caller belongs to, never at the target.
132
+ */
133
+ declare const SubtenantPermissions: {
134
+ /** Read any tenant below one you belong to. */
135
+ readonly TreeRead: "subtenants:tree:read";
136
+ /** Rename a tenant below yours, and add or remove its members. */
137
+ readonly TreeManage: "subtenants:tree:manage";
138
+ /** Create a tenant underneath one you belong to. */
139
+ readonly TreeCreate: "subtenants:tree:create";
140
+ /**
141
+ * Make a tenant below yours the ACTIVE tenant of the session — acting inside
142
+ * it rather than on it from outside. Deliberately not the same permission as
143
+ * {@link SubtenantPermissions.TreeManage}: administering a sub-tenant through
144
+ * the management endpoints and operating as that sub-tenant are different
145
+ * powers, and granting the first should not hand over the second.
146
+ */
147
+ readonly TreeSwitch: "subtenants:tree:switch";
148
+ };
149
+ type TenantPermission = (typeof TenantPermissions)[keyof typeof TenantPermissions];
150
+ type SubtenantPermission = (typeof SubtenantPermissions)[keyof typeof SubtenantPermissions];
151
+
152
+ export { AuthyonClient, AuthyonClientBuilder, AuthyonError, type AuthyonErrorAction, type AuthyonErrorBody, type AuthyonErrorCategory, type AuthyonErrorInterpretation, ErrorCodes, HttpAdapter, HttpLoggerOptions, type KnownErrorCode, type SubtenantPermission, SubtenantPermissions, type TenantPermission, TenantPermissions, TokenStorage, createDefaultStorage, createLocalStorage, createMemoryStorage };