@authyon/auth 0.2.0-beta.20 → 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,
@@ -269,7 +271,14 @@ function appendQuery(path2, params) {
269
271
  if (!params) return path2;
270
272
  const query = new URLSearchParams();
271
273
  for (const [key, value2] of Object.entries(params)) {
272
- if (value2 !== void 0) query.set(key, String(value2));
274
+ if (value2 === void 0) continue;
275
+ if (Array.isArray(value2)) {
276
+ for (const item of value2) {
277
+ if (item !== void 0) query.append(key, String(item));
278
+ }
279
+ continue;
280
+ }
281
+ query.set(key, String(value2));
273
282
  }
274
283
  const value = query.toString();
275
284
  return value ? `${path2}${path2.includes("?") ? "&" : "?"}${value}` : path2;
@@ -467,9 +476,11 @@ var JsonHttpClient = class {
467
476
  };
468
477
 
469
478
  // ../../internal/core/http/clientIp.ts
479
+ var CLIENT_IP_HEADER = "X-Authyon-Client-Ip";
470
480
  function clientIpHeaders(context) {
471
481
  if (!context?.clientIp) return {};
472
- return { "X-Forwarded-For": normalizeClientIp(context.clientIp) };
482
+ const ip = normalizeClientIp(context.clientIp);
483
+ return { "X-Forwarded-For": ip, [CLIENT_IP_HEADER]: ip };
473
484
  }
474
485
  function normalizeClientIp(value) {
475
486
  let ip = value.trim();
@@ -598,26 +609,59 @@ var AuthyonClient = class {
598
609
  };
599
610
  // ── Organization ─────────────────────────────────────────────────────────
600
611
  this.organization = {
601
- /** 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
+ */
602
621
  list: (params = {}) => this.request(appendQuery("/auth/tenants", params), { bearer: true }),
603
622
  /**
604
623
  * POST /auth/tenants — creates an organization owned by the signed-in
605
624
  * user (only available when self-service organization creation is
606
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.
607
632
  */
608
633
  create: (params = {}) => this.request("/auth/tenants", { method: "POST", bearer: true, body: params }),
609
634
  /** GET /auth/tenants/{organizationId} — fetch one of the user's organizations by id. */
610
635
  get: (organizationId) => this.request(`/auth/tenants/${encodeURIComponent(organizationId)}`, { bearer: true }),
611
636
  /**
612
637
  * PATCH /auth/tenants/{organizationId} — renames the organization.
613
- * 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.
614
644
  */
615
645
  rename: (organizationId, name) => this.request(`/auth/tenants/${encodeURIComponent(organizationId)}`, {
616
646
  method: "PATCH",
617
647
  bearer: true,
618
648
  body: { name }
619
649
  }),
620
- /** 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
+ */
621
665
  switch: (organizationSlug) => this.request("/auth/switch-tenant", {
622
666
  method: "POST",
623
667
  bearer: true,
@@ -931,7 +975,21 @@ var AuthyonClient = class {
931
975
  return this.http.parse(response);
932
976
  }
933
977
  // ── Auth flows ───────────────────────────────────────────────────────────
934
- /** 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
+ */
935
993
  async register(params) {
936
994
  return this.request("/auth/register", { method: "POST", body: params });
937
995
  }
@@ -1288,12 +1346,29 @@ function createAuthyonAbility(source = {}, options = {}) {
1288
1346
  return new AuthyonAbility(createAuthyonRules(source, options), options.detectSubjectType);
1289
1347
  }
1290
1348
  function hasPermission(source, requiredPermission) {
1291
- const requirement = permissionToRule(requiredPermission);
1292
- if (!requirement || typeof requirement.action !== "string" || typeof requirement.subject !== "string") {
1293
- return false;
1294
- }
1349
+ const required = requiredPermission?.trim() ?? "";
1350
+ if (!isWellFormedPermission(required)) return false;
1295
1351
  const permissionSource = isPermissionList(source) ? { permissions: source } : source;
1296
- 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);
1297
1372
  }
1298
1373
  function hasPermissionGroup(source, group) {
1299
1374
  const hasAll = (group.allOf ?? []).every((permission) => hasPermission(source, permission));
@@ -1308,13 +1383,23 @@ function createAuthyonRules(source = {}, options = {}) {
1308
1383
  ...source.permissions ?? [],
1309
1384
  ...source.scope?.split(/\s+/).filter(Boolean) ?? []
1310
1385
  ]);
1311
- const rules = [...permissions].map(permissionToRule).filter(isAbilityRule);
1386
+ const rules = [...permissions].map(grantToRule).filter(isAbilityRule);
1312
1387
  for (const role of /* @__PURE__ */ new Set([...source.roles ?? [], ...options.roles ?? []])) {
1313
1388
  rules.push(...(options.roleRules?.[role] ?? []).map(cloneRule));
1314
1389
  }
1315
1390
  rules.push(...(options.rules ?? []).map(cloneRule));
1316
1391
  return rules;
1317
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
+ }
1318
1403
  function permissionToRule(permission) {
1319
1404
  const normalized = permission.trim();
1320
1405
  if (!normalized) return null;
@@ -1411,6 +1496,32 @@ function isRecord(value) {
1411
1496
  function isAbilityRule(value) {
1412
1497
  return value !== null;
1413
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
+ };
1414
1525
  // Annotate the CommonJS export names for ESM import in node:
1415
1526
  0 && (module.exports = {
1416
1527
  AuthyonAbility,
@@ -1422,6 +1533,8 @@ function isAbilityRule(value) {
1422
1533
  ErrorCodes,
1423
1534
  FetchHttpAdapter,
1424
1535
  LoggingHttpAdapter,
1536
+ SubtenantPermissions,
1537
+ TenantPermissions,
1425
1538
  createAuthyonAbility,
1426
1539
  createAuthyonRules,
1427
1540
  createClient,