@owlmeans/iam 0.1.18-rc.9 → 0.1.18

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 (110) hide show
  1. package/README.md +185 -15
  2. package/agent-meta/manifest.json +92 -4
  3. package/agent-meta/skills/iam/SKILL.md +91 -177
  4. package/agent-meta/skills/iam/parts/clients.md +93 -0
  5. package/agent-meta/skills/iam/parts/permissions.md +106 -0
  6. package/agent-meta/skills/iam/parts/service.md +115 -0
  7. package/agent-meta/skills/iam/parts/tenancy.md +72 -0
  8. package/build/consts.d.ts +170 -1
  9. package/build/consts.d.ts.map +1 -1
  10. package/build/consts.js +221 -0
  11. package/build/consts.js.map +1 -1
  12. package/build/consts.local.d.ts +58 -0
  13. package/build/consts.local.d.ts.map +1 -0
  14. package/build/consts.local.js +21 -0
  15. package/build/consts.local.js.map +1 -0
  16. package/build/errors.d.ts +26 -0
  17. package/build/errors.d.ts.map +1 -1
  18. package/build/errors.js +38 -0
  19. package/build/errors.js.map +1 -1
  20. package/build/facet.d.ts +10 -0
  21. package/build/facet.d.ts.map +1 -0
  22. package/build/facet.js +16 -0
  23. package/build/facet.js.map +1 -0
  24. package/build/gate/consts.local.d.ts +2 -0
  25. package/build/gate/consts.local.d.ts.map +1 -0
  26. package/build/gate/consts.local.js +3 -0
  27. package/build/gate/consts.local.js.map +1 -0
  28. package/build/gate/index.d.ts +4 -0
  29. package/build/gate/index.d.ts.map +1 -0
  30. package/build/gate/index.js +4 -0
  31. package/build/gate/index.js.map +1 -0
  32. package/build/gate/param.d.ts +6 -0
  33. package/build/gate/param.d.ts.map +1 -0
  34. package/build/gate/param.js +83 -0
  35. package/build/gate/param.js.map +1 -0
  36. package/build/gate/resolve.d.ts +10 -0
  37. package/build/gate/resolve.d.ts.map +1 -0
  38. package/build/gate/resolve.js +93 -0
  39. package/build/gate/resolve.js.map +1 -0
  40. package/build/gate/types.d.ts +109 -0
  41. package/build/gate/types.d.ts.map +1 -0
  42. package/build/gate/types.js +2 -0
  43. package/build/gate/types.js.map +1 -0
  44. package/build/gate/types.local.d.ts +5 -0
  45. package/build/gate/types.local.d.ts.map +1 -0
  46. package/build/gate/types.local.js +2 -0
  47. package/build/gate/types.local.js.map +1 -0
  48. package/build/gate/validate.d.ts +4 -0
  49. package/build/gate/validate.d.ts.map +1 -0
  50. package/build/gate/validate.js +100 -0
  51. package/build/gate/validate.js.map +1 -0
  52. package/build/index.d.ts +9 -0
  53. package/build/index.d.ts.map +1 -1
  54. package/build/index.js +5 -0
  55. package/build/index.js.map +1 -1
  56. package/build/permission/types.d.ts +35 -0
  57. package/build/permission/types.d.ts.map +1 -0
  58. package/build/permission/types.js +2 -0
  59. package/build/permission/types.js.map +1 -0
  60. package/build/permission.d.ts +8 -0
  61. package/build/permission.d.ts.map +1 -0
  62. package/build/permission.js +34 -0
  63. package/build/permission.js.map +1 -0
  64. package/build/runtime/types.d.ts +90 -0
  65. package/build/runtime/types.d.ts.map +1 -0
  66. package/build/runtime/types.js +2 -0
  67. package/build/runtime/types.js.map +1 -0
  68. package/build/runtime.d.ts +75 -0
  69. package/build/runtime.d.ts.map +1 -0
  70. package/build/runtime.js +75 -0
  71. package/build/runtime.js.map +1 -0
  72. package/build/schemas.d.ts +13 -0
  73. package/build/schemas.d.ts.map +1 -0
  74. package/build/schemas.js +108 -0
  75. package/build/schemas.js.map +1 -0
  76. package/build/types.d.ts +429 -30
  77. package/build/types.d.ts.map +1 -1
  78. package/build/utils/types.d.ts +12 -0
  79. package/build/utils/types.d.ts.map +1 -0
  80. package/build/utils/types.js +2 -0
  81. package/build/utils/types.js.map +1 -0
  82. package/build/utils.d.ts +7 -6
  83. package/build/utils.d.ts.map +1 -1
  84. package/build/utils.js +10 -1
  85. package/build/utils.js.map +1 -1
  86. package/package.json +12 -7
  87. package/src/consts.local.ts +26 -0
  88. package/src/consts.ts +241 -1
  89. package/src/errors.ts +44 -0
  90. package/src/facet.ts +17 -0
  91. package/src/gate/consts.local.ts +3 -0
  92. package/src/gate/index.ts +3 -0
  93. package/src/gate/param.ts +108 -0
  94. package/src/gate/resolve.ts +114 -0
  95. package/src/gate/types.local.ts +4 -0
  96. package/src/gate/types.ts +117 -0
  97. package/src/gate/validate.ts +123 -0
  98. package/src/index.ts +9 -0
  99. package/src/permission/types.ts +33 -0
  100. package/src/permission.ts +48 -0
  101. package/src/runtime/types.ts +107 -0
  102. package/src/runtime.ts +142 -0
  103. package/src/schemas.ts +122 -0
  104. package/src/types.ts +492 -31
  105. package/src/utils/types.ts +11 -0
  106. package/src/utils.ts +12 -7
  107. package/tests/gate-param.spec.ts +233 -0
  108. package/tests/runtime.spec.ts +101 -0
  109. package/tests/tsconfig.json +12 -0
  110. package/tests/utils.spec.ts +47 -1
package/build/utils.js CHANGED
@@ -1,8 +1,17 @@
1
+ /** The organization a set is bound to, when it is a claim set that kept its binding. */
2
+ const boundTo = (set) => set.entitySlug;
1
3
  /**
2
4
  * True when any PermissionSet grants `permission`. An unscoped set (no resources)
3
5
  * satisfies a resourceId check too — a project-wide grant covers every resource.
6
+ *
7
+ * The organization follows the same rule the other way round, fail-closed: a set without a binding
8
+ * applies everywhere, a bound one only when `opts.entitySlug` names its organization. A relying
9
+ * party strips the binding from the sets of the organization a session acts in and drops every
10
+ * other bound set, so a bound set reaching a check is one that leaked — and it must not count
11
+ * merely because the caller did not say where the request acts.
4
12
  */
5
13
  export const hasPermission = (auth, permission, opts) => auth.permissions?.some(set => (opts?.scope == null || set.scope === opts.scope)
6
14
  && set.permissions[permission] === true
7
- && (opts?.resourceId == null || set.resources == null || set.resources.includes(opts.resourceId))) ?? false;
15
+ && (opts?.resourceId == null || set.resources == null || set.resources.includes(opts.resourceId))
16
+ && (boundTo(set) == null || (opts?.entitySlug != null && boundTo(set) === opts.entitySlug))) ?? false;
8
17
  //# sourceMappingURL=utils.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AASA;;;GAGG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAC3B,IAAmB,EACnB,UAAkB,EAClB,IAA2B,EAClB,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CACzC,CAAC,IAAI,EAAE,KAAK,IAAI,IAAI,IAAI,GAAG,CAAC,KAAK,KAAK,IAAI,CAAC,KAAK,CAAC;OAC9C,GAAG,CAAC,WAAW,CAAC,UAAU,CAAC,KAAK,IAAI;OACpC,CAAC,IAAI,EAAE,UAAU,IAAI,IAAI,IAAI,GAAG,CAAC,SAAS,IAAI,IAAI,IAAI,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAClG,IAAI,KAAK,CAAA"}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAIA,wFAAwF;AACxF,MAAM,OAAO,GAAG,CAAC,GAAkB,EAAsB,EAAE,CAAE,GAA8B,CAAC,UAAU,CAAA;AAEtG;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAC3B,IAAmB,EACnB,UAAkB,EAClB,IAA2B,EAClB,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,CACzC,CAAC,IAAI,EAAE,KAAK,IAAI,IAAI,IAAI,GAAG,CAAC,KAAK,KAAK,IAAI,CAAC,KAAK,CAAC;OAC9C,GAAG,CAAC,WAAW,CAAC,UAAU,CAAC,KAAK,IAAI;OACpC,CAAC,IAAI,EAAE,UAAU,IAAI,IAAI,IAAI,GAAG,CAAC,SAAS,IAAI,IAAI,IAAI,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;OAC9F,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,EAAE,UAAU,IAAI,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,UAAU,CAAC,CAAC,CAC5F,IAAI,KAAK,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/iam",
3
- "version": "0.1.18-rc.9",
3
+ "version": "0.1.18",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -22,14 +22,19 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@owlmeans/auth": "^0.1.18-rc.6",
26
- "@owlmeans/context": "^0.1.18-rc.6",
27
- "@owlmeans/error": "^0.1.18-rc.6",
28
- "@owlmeans/oidc": "^0.1.18-rc.8"
25
+ "@owlmeans/auth": "^0.1.18",
26
+ "@owlmeans/context": "^0.1.18",
27
+ "@owlmeans/entrypoint": "^0.1.18",
28
+ "@owlmeans/error": "^0.1.18",
29
+ "@owlmeans/oidc": "^0.1.18",
30
+ "@owlmeans/route": "^0.1.18"
31
+ },
32
+ "peerDependencies": {
33
+ "ajv": "*"
29
34
  },
30
35
  "devDependencies": {
31
- "@owlmeans/dep-config": "workspace:*",
32
- "@types/bun": "^1.3.14",
36
+ "@owlmeans/dep-config": "^0.1.18",
37
+ "@types/bun": "^1.4.2",
33
38
  "nodemon": "^3.1.14",
34
39
  "typescript": "^7.0.2"
35
40
  },
@@ -0,0 +1,26 @@
1
+ export const TitleSchema = { type: 'string', minLength: 0, maxLength: 256 } as const
2
+
3
+ /** A pairwise subject: `<clientId>:<digest>`. Never a path separator, never whitespace. */
4
+ export const ProfileIdSchema = { type: 'string', minLength: 1, maxLength: 256, pattern: '^[^/\\s]+$' } as const
5
+
6
+ export const PermissionNameSchema = { type: 'string', minLength: 1, maxLength: 128 } as const
7
+
8
+ export const EmailSchema = { type: 'string', format: 'email', minLength: 3, maxLength: 254 } as const
9
+
10
+ export const ResourcesSchema = {
11
+ type: 'array', items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 256, nullable: true,
12
+ } as const
13
+
14
+ export const aliases = {
15
+ base: 'iam-runtime',
16
+ organization: { list: 'iam-runtime:organization:list', create: 'iam-runtime:organization:create', update: 'iam-runtime:organization:update' },
17
+ member: {
18
+ list: 'iam-runtime:member:list', add: 'iam-runtime:member:add',
19
+ update: 'iam-runtime:member:update', remove: 'iam-runtime:member:remove',
20
+ },
21
+ permission: { list: 'iam-runtime:permission:list' },
22
+ grant: { list: 'iam-runtime:grant:list', assign: 'iam-runtime:grant:assign', revoke: 'iam-runtime:grant:revoke' },
23
+ } as const
24
+
25
+ /** Keys a facet stand-in answers `undefined` for, so it is never mistaken for a thenable or a primitive. */
26
+ export const INERT_KEYS = new Set<PropertyKey>(['then', 'toJSON'])
package/src/consts.ts CHANGED
@@ -1,6 +1,246 @@
1
+ import { EmailSchema, ProfileIdSchema, TitleSchema } from './consts.local.js'
2
+ import type { IamRuntimeAck, IamRuntimeGrantQuery, IamRuntimeMemberInvite, IamRuntimeMemberParams, IamRuntimeOrganizationCreate, IamRuntimeOrganizationParams, IamRuntimeOrganizationUpdate } from './runtime/types.js'
3
+ import type { JSONSchemaType } from 'ajv'
4
+ import { EntitySlugValueSchema } from '@owlmeans/auth'
5
+
1
6
  export const DEFAULT_ALIAS = 'iam-service'
2
7
 
3
8
  export const IAM_MODE_KEYCLOAK = 'keycloak'
4
9
  export const IAM_MODE_INTEGRATED = 'integrated'
5
10
 
6
- export type IamMode = typeof IAM_MODE_KEYCLOAK | typeof IAM_MODE_INTEGRATED
11
+ /**
12
+ * Gate-param syntax: `<permission>[@<selector>]`.
13
+ *
14
+ * The `@` belongs to the GATE and to nothing else. It says where a resource id is read from at
15
+ * request time; it is never part of a permission's stored NAME. A definition or a grant whose name
16
+ * carries one is a key nothing ever looks up, so every grant against it is a silent no-op.
17
+ */
18
+ export const RESOURCE_PARAM_SEPARATOR = '@'
19
+
20
+ /**
21
+ * Separates an explicit source from its path: `@body:order.id`.
22
+ *
23
+ * Deliberately `:` rather than a dot. Under a dotted-only form the meaning of a selector changes the
24
+ * moment a route gains a param named like a source keyword — `/report/:query` with `@query.id` flips
25
+ * from "the route param literally named `query.id`" to "the query string's `id`", redirecting an
26
+ * authorization lookup from a router-matched value to attacker-supplied input, caused by an
27
+ * unrelated route rename. `:` is decidable in one character, and no gate-param string written before
28
+ * this existed can contain one.
29
+ */
30
+ export const RESOURCE_SOURCE_SEPARATOR = ':'
31
+
32
+ /** Separates path segments inside a qualified selector: `@body:order.id`. */
33
+ export const RESOURCE_PATH_SEPARATOR = '.'
34
+
35
+ /** Where a qualified selector may read a resource id from. */
36
+ export enum GateParamSource {
37
+ Params = 'params',
38
+ Query = 'query',
39
+ Body = 'body',
40
+ Headers = 'headers',
41
+ Auth = 'auth'
42
+ }
43
+
44
+ /**
45
+ * What the bare form (`@enquiryId`) searches, in order.
46
+ *
47
+ * Params first, then query. The query fallback is legacy and deliberately kept: removing it would
48
+ * silently deny already-deployed applications whose selector happens to name a query key, and a
49
+ * silent denial inside a customer's running app is the worst available outcome.
50
+ */
51
+ export const DEFAULT_GATE_PARAM_SOURCES: GateParamSource[] = [
52
+ GateParamSource.Params, GateParamSource.Query
53
+ ]
54
+
55
+ /** Why a gate param is structurally wrong — a misconfiguration, not a failed authorization. */
56
+ export enum GateParamErrorCode {
57
+ UnknownSource = 'gate-param:unknown-source',
58
+ EmptySelector = 'gate-param:empty-selector',
59
+ EmptySegment = 'gate-param:empty-segment',
60
+ /** Named a request key the entrypoint's filter does not declare — AJV strips it before the gate. */
61
+ UnreachableKey = 'gate-param:unreachable-key',
62
+ /** Named a route param the entrypoint's own path does not carry. */
63
+ UnknownRouteParam = 'gate-param:unknown-route-param'
64
+ }
65
+
66
+ /** Why a selector produced no resource id. */
67
+ export enum GateResolutionFailure {
68
+ /** The source exists, the path does not. */
69
+ NotProvided = 'not-provided',
70
+ /** The request carries no such source at all. */
71
+ SourceMissing = 'source-missing',
72
+ /** Resolved to an object or a multi-element array — refused rather than stringified. */
73
+ NotScalar = 'not-scalar',
74
+ /** The selector itself did not parse. */
75
+ Malformed = 'malformed'
76
+ }
77
+
78
+ /**
79
+ * Which FORM of a grant an operation addresses.
80
+ *
81
+ * A resource-scoped permission is grantable two ways, under one name: `Blanket` (a PermissionSet
82
+ * carrying no `resources`, which `hasPermission` reads as covering every id) and `Resources` (an
83
+ * explicit id list). They are stored separately and must be revoked separately, or a UI offering
84
+ * both controls would have one silently wipe the other.
85
+ *
86
+ * Every subject, person or group, also has ONE blanket value of its own per binding: `true` grants,
87
+ * `false` RESTRICTS (it cuts what the subject would inherit from a default class, a group or a
88
+ * bundle), absent inherits. A `Blanket` or `All` revoke of a declared definition writes the
89
+ * restriction; `Inherit` takes the subject's own value back.
90
+ */
91
+ export enum IamGrantMode {
92
+ Blanket = 'blanket',
93
+ Resources = 'resources',
94
+ /** Revoke-only: every form. Illegal on a grant. */
95
+ All = 'all',
96
+ /**
97
+ * Revoke-only: drop the subject's OWN blanket value in the binding — a grant or a restriction —
98
+ * so it holds exactly what it inherits. Resource-scoped sets are untouched. Illegal on a grant.
99
+ */
100
+ Inherit = 'inherit'
101
+ }
102
+
103
+ /** What to do about grants that still reference a definition being removed. */
104
+ export enum IamRemovalPolicy {
105
+ /** Strip the permission from every subject of the entity, then drop the definition. */
106
+ Cascade = 'cascade',
107
+ /** Refuse while any subject still holds it; the holders travel in the error. */
108
+ Refuse = 'refuse'
109
+ }
110
+
111
+ /**
112
+ * The grouping tiers a permission definition may be tagged with.
113
+ *
114
+ * A tag decides NOTHING at request time — `hasPermission` and every gate treat a permission name as
115
+ * one opaque string — and only groups the permission in an administrator's screen. `guest` is
116
+ * deliberately absent: a public surface declares no permissions, and adding an area later would
117
+ * re-classify names already tagged.
118
+ */
119
+ export const IAM_AREAS = ['user', 'operator', 'admin'] as const
120
+
121
+ /** Separates the resource from the action in a permission name. TWO hyphens, never one. */
122
+ export const PERMISSION_ACTION_SEPARATOR = '--'
123
+
124
+ /**
125
+ * Who holds a permission without an explicit grant — a property of the DEFINITION, evaluated when
126
+ * claims are built and never written onto a profile.
127
+ *
128
+ * `User` is every signed-in subject of the client (an unbound, unmanaged `user`-area definition
129
+ * only); `Member` and `Owner` are every member / owner of the organization the subject acts in, so
130
+ * they require an `entityScoped` definition. A class default is a BLANKET set: `Owner` on a
131
+ * resource-scoped definition is its all-records form.
132
+ */
133
+ export enum IamDefaultClass {
134
+ None = 'none',
135
+ User = 'user',
136
+ Member = 'member',
137
+ Owner = 'owner'
138
+ }
139
+
140
+ /** Why a subject holds a grant, as a listing reports it. */
141
+ export enum IamGrantOrigin {
142
+ /** Written on the subject's own row. */
143
+ Direct = 'direct',
144
+ /** The definition's `defaultClass` covers the subject. */
145
+ Default = 'default',
146
+ /** Held by a group the subject is a member of — `IamGrant.through` names it. */
147
+ Group = 'group',
148
+ /** A group's own listing only: the names its bundles select; its own grants are `Direct`. */
149
+ Bundle = 'bundle'
150
+ }
151
+
152
+ /**
153
+ * The managed group `syncStaff` keeps in a client's owning organization: the owner's own staff,
154
+ * admitted to the client with the bundles the sync hands it. Its key, membership, bundles and
155
+ * existence are the synchronization's alone; its grants and restrictions are the operator's and
156
+ * survive every sync.
157
+ */
158
+ export const IAM_MEMBERS_GROUP = 'members'
159
+
160
+ /**
161
+ * The `profileId` of a credential that proves an e-mail address and identifies nobody yet: what the
162
+ * end-user sign-in hands the provider's finalizer, which then resolves the account itself. It carries
163
+ * no `entitySlug` — a proof bound to an organization is something else.
164
+ */
165
+ export const IAM_EMAIL_PROOF = 'proof:email'
166
+
167
+ /**
168
+ * The guard of the runtime IAM API (`makeIamRuntimeProtocols`). The serving process registers it: it
169
+ * admits a bearer access token the provider itself issued, and takes the client and the account from
170
+ * that token, never from the request.
171
+ */
172
+ export const IAM_RUNTIME_GUARD = 'iam-runtime-guard'
173
+
174
+ /** Where the runtime IAM API mounts under its service unless the declaration names another path. */
175
+ export const IAM_RUNTIME_PATH = '/runtime'
176
+
177
+ /**
178
+ * The runtime API's leaf paths under its base. One table so the declarations and a fetch-only client
179
+ * (a target's server, which binds no entrypoint of the provider's tree) cannot disagree on a segment.
180
+ */
181
+ export const IAM_RUNTIME_ROUTES = {
182
+ organizations: '/organizations',
183
+ organization: '/organizations/:entitySlug',
184
+ members: '/organizations/:entitySlug/members',
185
+ member: '/organizations/:entitySlug/members/:profileId',
186
+ memberRemove: '/organizations/:entitySlug/members/:profileId/remove',
187
+ permissions: '/organizations/:entitySlug/permissions',
188
+ grants: '/organizations/:entitySlug/grants',
189
+ grantsRevoke: '/organizations/:entitySlug/grants/revoke',
190
+ } as const
191
+
192
+ export const IamRuntimeOrganizationCreateSchema: JSONSchemaType<IamRuntimeOrganizationCreate> = {
193
+ type: 'object',
194
+ properties: { title: { ...TitleSchema, nullable: true } },
195
+ required: [],
196
+ additionalProperties: false,
197
+ }
198
+
199
+ export const IamRuntimeOrganizationUpdateSchema: JSONSchemaType<IamRuntimeOrganizationUpdate> = {
200
+ type: 'object',
201
+ properties: { title: { ...TitleSchema } },
202
+ required: ['title'],
203
+ additionalProperties: false,
204
+ }
205
+
206
+ export const IamRuntimeOrganizationParamsSchema: JSONSchemaType<IamRuntimeOrganizationParams> = {
207
+ type: 'object',
208
+ properties: { entitySlug: { ...EntitySlugValueSchema } },
209
+ required: ['entitySlug'],
210
+ additionalProperties: false,
211
+ }
212
+
213
+ export const IamRuntimeMemberInviteSchema: JSONSchemaType<IamRuntimeMemberInvite> = {
214
+ type: 'object',
215
+ properties: {
216
+ email: { ...EmailSchema },
217
+ name: { ...TitleSchema, nullable: true },
218
+ owner: { type: 'boolean', nullable: true },
219
+ },
220
+ required: ['email'],
221
+ additionalProperties: false,
222
+ }
223
+
224
+ export const IamRuntimeMemberParamsSchema: JSONSchemaType<IamRuntimeMemberParams> = {
225
+ type: 'object',
226
+ properties: {
227
+ entitySlug: { ...EntitySlugValueSchema },
228
+ profileId: { ...ProfileIdSchema },
229
+ },
230
+ required: ['entitySlug', 'profileId'],
231
+ additionalProperties: false,
232
+ }
233
+
234
+ export const IamRuntimeGrantQuerySchema: JSONSchemaType<IamRuntimeGrantQuery> = {
235
+ type: 'object',
236
+ properties: { profileId: { ...ProfileIdSchema, nullable: true } },
237
+ required: [],
238
+ additionalProperties: false,
239
+ }
240
+
241
+ export const IamRuntimeAckSchema: JSONSchemaType<IamRuntimeAck> = {
242
+ type: 'object',
243
+ properties: { ok: { type: 'boolean' } },
244
+ required: ['ok'],
245
+ additionalProperties: false,
246
+ }
package/src/errors.ts CHANGED
@@ -36,6 +36,24 @@ export class IamGrantError extends IamError {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * A refusal about a permission DEFINITION, as opposed to a grant.
41
+ *
42
+ * Separate from `IamGrantError`, which means a grant's subject is missing or its entity does not
43
+ * match — reusing it for a definition-level refusal is a category error the next reader has to unpick.
44
+ *
45
+ * Codes: `permission:held:<name>` (still granted to someone, under a policy that refuses),
46
+ * `permission:managed:<name>` (platform-owned, deleted only with an explicit second key).
47
+ */
48
+ export class IamPermissionError extends IamError {
49
+ public static override typeName = `IamPermission${ResilientError.typeName}`
50
+
51
+ constructor(message = 'error') {
52
+ super(`permission:${message}`)
53
+ this.type = IamPermissionError.typeName
54
+ }
55
+ }
56
+
39
57
  export class IamUnsupported extends IamError {
40
58
  public static override typeName = `IamUnsupported${ResilientError.typeName}`
41
59
 
@@ -54,9 +72,35 @@ export class IamUserError extends IamError {
54
72
  }
55
73
  }
56
74
 
75
+ /**
76
+ * A refusal about an organization or a membership in it — an unknown organization, the last owner,
77
+ * the membership cap.
78
+ */
79
+ export class IamOrganizationError extends IamError {
80
+ public static override typeName = `IamOrganization${ResilientError.typeName}`
81
+
82
+ constructor(message = 'error') {
83
+ super(`organization:${message}`)
84
+ this.type = IamOrganizationError.typeName
85
+ }
86
+ }
87
+
88
+ /** A refusal about a group: an unknown key, a managed group written by an operator, another app's group. */
89
+ export class IamGroupError extends IamError {
90
+ public static override typeName = `IamGroup${ResilientError.typeName}`
91
+
92
+ constructor(message = 'error') {
93
+ super(`group:${message}`)
94
+ this.type = IamGroupError.typeName
95
+ }
96
+ }
97
+
57
98
  ResilientError.registerErrorClass(IamError)
58
99
  ResilientError.registerErrorClass(IamClientError)
59
100
  ResilientError.registerErrorClass(IamResourceError)
60
101
  ResilientError.registerErrorClass(IamGrantError)
102
+ ResilientError.registerErrorClass(IamPermissionError)
61
103
  ResilientError.registerErrorClass(IamUnsupported)
62
104
  ResilientError.registerErrorClass(IamUserError)
105
+ ResilientError.registerErrorClass(IamOrganizationError)
106
+ ResilientError.registerErrorClass(IamGroupError)
package/src/facet.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { IamUnsupported } from './errors.js'
2
+ import { INERT_KEYS } from './consts.local.js'
3
+
4
+ /**
5
+ * A facet a backend cannot provide: every method throws `IamUnsupported(what)`.
6
+ *
7
+ * It throws SYNCHRONOUSLY, because a facet may carry synchronous methods (`subjects.identify`) and a
8
+ * rejected promise would hand those a value of the wrong type; an awaited call rejects the same way
9
+ * either way. Caller code turns the refusal into a fallback, exactly as for any other unsupported
10
+ * operation.
11
+ */
12
+ export const unsupportedFacet = <T extends object>(what: string): T =>
13
+ new Proxy(Object.freeze({}) as T, {
14
+ get: (_, key) => typeof key === 'symbol' || INERT_KEYS.has(key)
15
+ ? undefined
16
+ : () => { throw new IamUnsupported(what) },
17
+ })
@@ -0,0 +1,3 @@
1
+ import { GateParamSource } from '../consts.js'
2
+
3
+ export const SOURCES = Object.values(GateParamSource) as string[]
@@ -0,0 +1,3 @@
1
+ export * from './param.js'
2
+ export * from './resolve.js'
3
+ export * from './validate.js'
@@ -0,0 +1,108 @@
1
+ import {
2
+ RESOURCE_PARAM_SEPARATOR, RESOURCE_SOURCE_SEPARATOR, RESOURCE_PATH_SEPARATOR,
3
+ GateParamSource, GateParamErrorCode, DEFAULT_GATE_PARAM_SOURCES
4
+ } from '../consts.js'
5
+ import type { GateParamHelper, GateResourceSelector, GateParamProblem, ParsedGateParam } from './types.js'
6
+ import { SOURCES } from './consts.local.js'
7
+
8
+ export const createGateParamHelper = (): GateParamHelper => {
9
+ const parseGateSelector = (selector: string): GateResourceSelector | GateParamProblem => {
10
+ if (selector === '') {
11
+ return { code: GateParamErrorCode.EmptySelector, detail: 'the selector after "@" is empty' }
12
+ }
13
+
14
+ const idx = selector.indexOf(RESOURCE_SOURCE_SEPARATOR)
15
+ if (idx < 0) {
16
+ return {
17
+ selector,
18
+ path: [selector],
19
+ sources: DEFAULT_GATE_PARAM_SOURCES
20
+ }
21
+ }
22
+
23
+ const source = selector.slice(0, idx)
24
+ const rest = selector.slice(idx + 1)
25
+
26
+ if (!SOURCES.includes(source)) {
27
+ return {
28
+ code: GateParamErrorCode.UnknownSource,
29
+ detail: `"${source}" is not one of ${SOURCES.join(', ')}`
30
+ }
31
+ }
32
+
33
+ if (rest === '') {
34
+ return {
35
+ code: GateParamErrorCode.EmptySelector,
36
+ detail: `"${source}:" names no path`
37
+ }
38
+ }
39
+
40
+ const path = rest.split(RESOURCE_PATH_SEPARATOR)
41
+ if (path.some(segment => segment === '')) {
42
+ return {
43
+ code: GateParamErrorCode.EmptySegment,
44
+ detail: `"${rest}" has an empty path segment`
45
+ }
46
+ }
47
+
48
+ return {
49
+ selector,
50
+ source: source as GateParamSource,
51
+ path,
52
+ sources: [source as GateParamSource]
53
+ }
54
+ }
55
+
56
+ const parseGateParam = (param: string): ParsedGateParam => {
57
+ const idx = param.indexOf(RESOURCE_PARAM_SEPARATOR)
58
+ if (idx < 0) {
59
+ return { permission: param }
60
+ }
61
+
62
+ const permission = param.slice(0, idx)
63
+ const selector = param.slice(idx + 1)
64
+ const parsed = parseGateSelector(selector)
65
+
66
+ if ('code' in parsed) {
67
+ return { permission, resourceParam: selector, error: parsed }
68
+ }
69
+
70
+ return {
71
+ permission,
72
+ // Kept for readers written against the flat form. Only meaningful when the form IS flat.
73
+ ...(parsed.source == null ? { resourceParam: selector } : {}),
74
+ resource: parsed
75
+ }
76
+ }
77
+
78
+ const formatGateParam = (
79
+ permission: string,
80
+ selector?: string | { source?: GateParamSource, path: string[] }
81
+ ): string => {
82
+ if (selector == null) {
83
+ return permission
84
+ }
85
+
86
+ if (typeof selector === 'string') {
87
+ return selector === '' ? permission : `${permission}${RESOURCE_PARAM_SEPARATOR}${selector}`
88
+ }
89
+
90
+ if (selector.path.length < 1) {
91
+ return permission
92
+ }
93
+
94
+ const path = selector.path.join(RESOURCE_PATH_SEPARATOR)
95
+ const tail = selector.source == null
96
+ ? path
97
+ : `${selector.source}${RESOURCE_SOURCE_SEPARATOR}${path}`
98
+
99
+ return `${permission}${RESOURCE_PARAM_SEPARATOR}${tail}`
100
+ }
101
+
102
+ return { parseGateSelector, parseGateParam, formatGateParam }
103
+ }
104
+
105
+ export const gateParamHelper = createGateParamHelper()
106
+
107
+ /** @deprecated compat:factory-refactor — use `gateParamHelper.parseGateParam(…)` */
108
+ export const parseGateParam = (param: string): ParsedGateParam => gateParamHelper.parseGateParam(param)
@@ -0,0 +1,114 @@
1
+ import { GateParamSource, GateResolutionFailure } from '../consts.js'
2
+ import type { GateRequestLike, GateResourceSelector, GateResourceResolution } from './types.js'
3
+
4
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
5
+ value != null && typeof value === 'object'
6
+
7
+ /**
8
+ * A resource id is a scalar. Anything else is refused rather than coerced.
9
+ *
10
+ * Stringifying an object would produce `"[object Object]"`, which then either matches nothing or —
11
+ * far worse — matches another record that stringified the same way.
12
+ */
13
+ const scalar = (value: unknown): string | null => {
14
+ switch (typeof value) {
15
+ case 'string':
16
+ return value
17
+ case 'number':
18
+ case 'boolean':
19
+ case 'bigint':
20
+ return `${value}`
21
+ default:
22
+ return null
23
+ }
24
+ }
25
+
26
+ const sourceOf = (req: GateRequestLike, source: GateParamSource): unknown => {
27
+ switch (source) {
28
+ case GateParamSource.Params: return req.params
29
+ case GateParamSource.Query: return req.query
30
+ case GateParamSource.Body: return req.body
31
+ case GateParamSource.Headers: return req.headers
32
+ case GateParamSource.Auth: return req.auth
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Walk one source for the selector's path.
38
+ *
39
+ * Headers get two adjustments they need and nothing else does: the first segment is lowercased,
40
+ * because HTTP header names are case-insensitive and every server normalises them differently; and a
41
+ * single-element array unwraps, because a repeated header arrives as an array and silently picking
42
+ * one of several values would be an authorization decision made by accident.
43
+ */
44
+ const walk = (
45
+ container: unknown, path: string[], source: GateParamSource
46
+ ): { value?: unknown, reason?: GateResolutionFailure } => {
47
+ if (!isRecord(container)) {
48
+ return { reason: GateResolutionFailure.SourceMissing }
49
+ }
50
+
51
+ let cursor: unknown = container
52
+ for (const [index, rawSegment] of path.entries()) {
53
+ if (!isRecord(cursor)) {
54
+ return { reason: GateResolutionFailure.NotProvided }
55
+ }
56
+
57
+ const segment = source === GateParamSource.Headers && index === 0
58
+ ? rawSegment.toLowerCase()
59
+ : rawSegment
60
+
61
+ cursor = cursor[segment]
62
+ }
63
+
64
+ if (source === GateParamSource.Headers && Array.isArray(cursor)) {
65
+ if (cursor.length !== 1) {
66
+ return { reason: GateResolutionFailure.NotScalar }
67
+ }
68
+ cursor = cursor[0]
69
+ }
70
+
71
+ if (cursor == null) {
72
+ return { reason: GateResolutionFailure.NotProvided }
73
+ }
74
+
75
+ return { value: cursor }
76
+ }
77
+
78
+ /**
79
+ * Resolve the resource id a selector points at.
80
+ *
81
+ * Sources are tried in the selector's own order — for the bare form, route params before the query —
82
+ * and the first that yields a value wins. When none does, the reason travels back so the caller can
83
+ * tell a MISCONFIGURED gate (the request never carried that key) from a legitimately DENIED one.
84
+ */
85
+ export const resolveGateResource = (
86
+ req: GateRequestLike, selector: GateResourceSelector
87
+ ): GateResourceResolution => {
88
+ let reason: GateResolutionFailure = GateResolutionFailure.SourceMissing
89
+
90
+ for (const source of selector.sources) {
91
+ const result = walk(sourceOf(req, source), selector.path, source)
92
+
93
+ if (result.reason != null) {
94
+ // A hard refusal is more informative than "keep looking" — report it rather than letting a
95
+ // later, emptier source overwrite it with a vaguer reason.
96
+ if (result.reason === GateResolutionFailure.NotScalar) {
97
+ return { reason: result.reason, from: source }
98
+ }
99
+ if (reason === GateResolutionFailure.SourceMissing) {
100
+ reason = result.reason
101
+ }
102
+ continue
103
+ }
104
+
105
+ const value = scalar(result.value)
106
+ if (value == null) {
107
+ return { reason: GateResolutionFailure.NotScalar, from: source }
108
+ }
109
+
110
+ return { id: value, from: source }
111
+ }
112
+
113
+ return { reason }
114
+ }
@@ -0,0 +1,4 @@
1
+ export interface SchemaLike {
2
+ properties?: Record<string, unknown>
3
+ additionalProperties?: unknown
4
+ }