@gnldev/auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Karaca Yılmaz (https://gnl.dev)
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # @gnldev/auth
2
+
3
+ The auth contract the rest of the framework speaks, plus a role-based default you can use as-is.
4
+
5
+ Auth is **opt-in**: leave it out and the REST API and Studio stay open (fine for local work). Wire a
6
+ provider in and every route is gated. In production, a missing provider is an error rather than a
7
+ silent open door.
8
+
9
+ ## Install
10
+
11
+ > Install: `pnpm add @gnldev/auth` — or use it from a [repo clone](https://github.com/Karaca7/gnldev): `pnpm install && pnpm -r build`.
12
+
13
+ ```bash
14
+ npm i @gnldev/auth
15
+ ```
16
+
17
+ ## Four credential classes
18
+
19
+ Pick the class by **who holds the token**, not by how much it needs to do. Credentials can be a bearer
20
+ token or a user/password pair for basic auth — comparison is constant-time.
21
+
22
+ | Class | Who holds it | Organization | Can |
23
+ |---|---|---|---|
24
+ | `superAdmin` | you, running the platform | none, by design | everything, across every organization |
25
+ | `admin` | a customer's operator | bound | manage that organization: Studio, budgets, users, workflows |
26
+ | `client` | a customer's **backend server** | bound | run agents, cancel runs, read — nothing else |
27
+ | `viewer` | a read-only operator | bound | read |
28
+
29
+ ```ts
30
+ import { roleAuth } from '@gnldev/auth';
31
+ import { createRestApi } from '@gnldev/server';
32
+
33
+ const auth = roleAuth({
34
+ // The credential your APPLICATION carries. It serves many end users under one token, so every
35
+ // request names the end user it acts for (see below).
36
+ client: { token: process.env.GNL_CLIENT_TOKEN, orgId: 'acme' },
37
+ // The credential a PERSON carries, for Studio.
38
+ admin: { token: process.env.GNL_ADMIN_TOKEN, orgId: 'acme' },
39
+ });
40
+
41
+ const app = createRestApi(config, { auth });
42
+ ```
43
+
44
+ `roleAuth` returns `undefined` when no class is configured — that is what keeps auth opt-in.
45
+
46
+ ### Give your application `client`, not `admin`
47
+
48
+ `admin` is an operator credential: it cancels runs, edits budgets, manages users and reads the whole
49
+ organization's history. An application that only needs to run an agent has no business holding that,
50
+ and if it leaks, none of those are things you wanted a leaked key to reach.
51
+
52
+ `client` is a **whitelist** — `agents:run`, `workflow:run`, `run:cancel`, plus reads. Anything else is
53
+ refused, including routes added in later versions. That is the direction you want the default to fail.
54
+
55
+ ### A `client` names the end user it acts for
56
+
57
+ One application credential serves many end users, so the request says which one:
58
+
59
+ ```ts
60
+ const GNL = process.env.GNL_URL!;
61
+ const CLIENT_TOKEN = process.env.GNL_CLIENT_TOKEN!;
62
+
63
+ await fetch(`${GNL}/agents/support/run`, {
64
+ method: 'POST',
65
+ headers: { authorization: `Bearer ${CLIENT_TOKEN}`, 'content-type': 'application/json' },
66
+ body: JSON.stringify({
67
+ runId: 'r-882',
68
+ prompt: 'where is my order',
69
+ threadId: 't-ayse-1',
70
+ resourceId: 'u-ayse', // ← WHOSE request this is
71
+ }),
72
+ });
73
+ ```
74
+
75
+ `resourceId` is what separates one end user's conversation, memory and runs from another's. A `client`
76
+ request that omits it is refused (`400`) rather than served unscoped — without a subject there is
77
+ nothing to keep two of your users apart. Operator classes may omit it: they work across the
78
+ organization by design.
79
+
80
+ You can then ask for one user's data, and have ownership checked:
81
+
82
+ ```
83
+ GET /runs?resourceId=u-ayse → only that user's runs
84
+ GET /threads?resourceId=u-ayse → only that user's conversations
85
+ GET /runs/r-882?resourceId=u-mehmet → 403, the run belongs to someone else
86
+ ```
87
+
88
+ > **Keep the client token on your server.** It is trusted to say who it acts for, so anyone holding it
89
+ > can claim any `resourceId`. That is safe in your backend, where you already know who your user is.
90
+ > It is not safe in a browser or a mobile app — ship neither the token nor a proxy that forwards a
91
+ > caller-supplied `resourceId` unchecked.
92
+
93
+ ### `superAdmin` is stated, never inferred
94
+
95
+ An identity belonging to no organization is **not** treated as an operator by accident. Once `org` is
96
+ configured, an unbound identity is refused unless it is declared `superAdmin`; otherwise a forgotten
97
+ `orgId` would quietly mint a cross-organization super-admin.
98
+
99
+ ## The contract
100
+
101
+ | Export | What it is |
102
+ |---|---|
103
+ | `AuthProvider` | `authenticate(request)` → a `Principal` or `null`, then `authorize(principal, request, ctx)` → `{ allow }` |
104
+ | `roleAuth` | The bundled provider above |
105
+ | `CLIENT_WRITES` | The exact set of writes a `client` may perform — read it rather than guessing |
106
+ | `PLATFORM_ADMIN_ROLE` / `isPlatformAdmin` | The reserved cross-organization grant `superAdmin` carries |
107
+ | `makeGate` | Turns a provider into a gate a host can apply to routes |
108
+ | `principalOf` | Reads the principal a gate resolved for a request |
109
+ | `normalizeAuth` / `fromReadWrite` | Accepts the older `{ read, write }` predicate pair and adapts it to the provider interface |
110
+
111
+ ## Extending it
112
+
113
+ `AuthProvider` is the seam. Anything implementing it works: your own JWT logic, an identity service,
114
+ or the paid `@gnldev/auth-ee`, which plugs into this same interface to add SSO, RBAC, fine-grained
115
+ authorization and a relational user store. Nothing in this package needs to change to swap providers.
116
+
117
+ A provider may also declare `capabilities()` — that is how a host learns whether features like
118
+ role-based access or multi-organization scoping are available.
119
+
120
+ ### What the paid provider adds on top of these four
121
+
122
+ The four classes above are **fixed**: `viewer` reads everything inside its organization, and there is
123
+ no way to narrow it. `@gnldev/auth-ee` replaces the classes with per-user permissions, so you choose
124
+ what each person sees:
125
+
126
+ ```
127
+ Ayşe runs:read ✓ threads:read ✗ money:read ✗ → sees that a run failed,
128
+ not what the customer typed into it
129
+ ```
130
+
131
+ Reads are named the same way writes are — `runs`, `threads`, `money`, `audit`, `users`, `catalog` —
132
+ and `*:read` still means all of them, so nothing an existing grant could reach becomes unreachable.
133
+ Isolation itself is **not** the paid part: the organization boundary, the `client` whitelist and the
134
+ `resourceId` rules above are all in this package, and none of them turn on when you pay.
135
+
136
+ ## License
137
+
138
+ Apache-2.0 — see [LICENSE](./LICENSE).
@@ -0,0 +1,38 @@
1
+ import type { AuthProvider } from './types.js';
2
+ /** Old role-based hook: read = GET (viewer), write = POST/PUT/PATCH/DELETE (admin). */
3
+ export interface ReadWriteAuth {
4
+ read?: (req: Request) => boolean | Promise<boolean>;
5
+ write?: (req: Request) => boolean | Promise<boolean>;
6
+ }
7
+ /**
8
+ * Can this provider ever produce a principal?
9
+ *
10
+ * `authenticate() === null` is ambiguous: it means EITHER "this request carried no token" (a
11
+ * roleAuth caller who should get 401) OR "this provider has no principal model at all and never
12
+ * will" (the {read,write} pair below). A host that scopes organizations by identity has to tell
13
+ * those apart — the first is a per-request condition, the second is a configuration that cannot
14
+ * isolate anything, because there is no identity to bind an org to. Collapsing them makes the
15
+ * unauthenticated caller's 401 turn into a misleading 403, or leaves the unsafe configuration open.
16
+ *
17
+ * Absent on hand-written providers, so `!== false` is the compatible reading: unknown means capable.
18
+ */
19
+ export declare function bindsIdentity(auth: AuthProvider | undefined): boolean;
20
+ /** {read,write} → AuthProvider. If there's no fn in that direction, it's unrestricted (preserves the existing `!fn || fn(c)` behavior). */
21
+ export declare function fromReadWrite(rw: ReadWriteAuth): AuthProvider;
22
+ /**
23
+ * AuthProvider | {read,write} | undefined → AuthProvider | undefined (hosts reduce to a single type).
24
+ *
25
+ * Throws on an object that is neither, because the alternative is the worst possible answer. The
26
+ * previous reading was "not a provider ⇒ it must be {read,write}", and `fromReadWrite` treats a
27
+ * missing direction as unrestricted — so ANY unrecognised object became a provider that allows
28
+ * everything. The realistic way to hit that is the credential map the scaffold writes:
29
+ *
30
+ * createRestApi({ auth: { admin: { token: 's3cret' } } }) // looks protected, allows everyone
31
+ *
32
+ * That exact shape shipped once already: @gnldev/studio grew `resolveConfigAuth` for it, but
33
+ * @gnldev/server still calls this directly, so the hole stayed open on the other host. Refusing here
34
+ * closes it for every caller and turns a silent, invisible failure into an error at startup — the one
35
+ * moment it can still be fixed. A caller that really wants no restrictions passes `undefined`; that
36
+ * intent is expressible and needs no guessing.
37
+ */
38
+ export declare function normalizeAuth(auth?: AuthProvider | ReadWriteAuth): AuthProvider | undefined;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Can this provider ever produce a principal?
3
+ *
4
+ * `authenticate() === null` is ambiguous: it means EITHER "this request carried no token" (a
5
+ * roleAuth caller who should get 401) OR "this provider has no principal model at all and never
6
+ * will" (the {read,write} pair below). A host that scopes organizations by identity has to tell
7
+ * those apart — the first is a per-request condition, the second is a configuration that cannot
8
+ * isolate anything, because there is no identity to bind an org to. Collapsing them makes the
9
+ * unauthenticated caller's 401 turn into a misleading 403, or leaves the unsafe configuration open.
10
+ *
11
+ * Absent on hand-written providers, so `!== false` is the compatible reading: unknown means capable.
12
+ */
13
+ export function bindsIdentity(auth) {
14
+ return !!auth && auth.bindsIdentity !== false;
15
+ }
16
+ /** {read,write} → AuthProvider. If there's no fn in that direction, it's unrestricted (preserves the existing `!fn || fn(c)` behavior). */
17
+ export function fromReadWrite(rw) {
18
+ return {
19
+ bindsIdentity: false,
20
+ authenticate() {
21
+ return null; // no principal model; the decision is made in the predicate.
22
+ },
23
+ async authorize(_p, req, ctx) {
24
+ const fn = ctx.action === 'read' ? rw.read : rw.write;
25
+ if (!fn)
26
+ return { allow: true };
27
+ const ok = await fn(req);
28
+ return ok ? { allow: true } : { allow: false, status: ctx.action === 'write' ? 403 : 401 };
29
+ },
30
+ };
31
+ }
32
+ function isProvider(x) {
33
+ return typeof x.authorize === 'function';
34
+ }
35
+ /**
36
+ * AuthProvider | {read,write} | undefined → AuthProvider | undefined (hosts reduce to a single type).
37
+ *
38
+ * Throws on an object that is neither, because the alternative is the worst possible answer. The
39
+ * previous reading was "not a provider ⇒ it must be {read,write}", and `fromReadWrite` treats a
40
+ * missing direction as unrestricted — so ANY unrecognised object became a provider that allows
41
+ * everything. The realistic way to hit that is the credential map the scaffold writes:
42
+ *
43
+ * createRestApi({ auth: { admin: { token: 's3cret' } } }) // looks protected, allows everyone
44
+ *
45
+ * That exact shape shipped once already: @gnldev/studio grew `resolveConfigAuth` for it, but
46
+ * @gnldev/server still calls this directly, so the hole stayed open on the other host. Refusing here
47
+ * closes it for every caller and turns a silent, invisible failure into an error at startup — the one
48
+ * moment it can still be fixed. A caller that really wants no restrictions passes `undefined`; that
49
+ * intent is expressible and needs no guessing.
50
+ */
51
+ export function normalizeAuth(auth) {
52
+ if (!auth)
53
+ return undefined;
54
+ if (isProvider(auth))
55
+ return auth;
56
+ const rw = auth;
57
+ // Each side that is PRESENT must be a function. The check used to be `read is fn OR write is fn`,
58
+ // which short-circuits: `{ read: fn, write: { admin: { token } } }` — one direction a predicate, the
59
+ // other a credential map, which is what a half-finished config looks like — passed here and then
60
+ // threw at REQUEST time, on the first write, in production. That is the silent invisible failure
61
+ // this whole function exists to move to startup.
62
+ const sides = ['read', 'write'].filter((k) => rw[k] !== undefined);
63
+ if (sides.length > 0) {
64
+ const bad = sides.filter((k) => typeof rw[k] !== 'function');
65
+ if (bad.length === 0)
66
+ return fromReadWrite(rw);
67
+ throw new TypeError(`@gnldev/auth: \`auth.${bad.join('\` and \`auth.')}\` must be a function ` +
68
+ `(got ${bad.map((k) => `${k}: ${typeof rw[k]}`).join(', ')}). ` +
69
+ `A {read, write} pair takes predicates; a credential map like { admin: { token } } has to be ` +
70
+ `wrapped: \`roleAuth({ admin: { token } })\`. Left as it is, this authorises nothing until the ` +
71
+ `first request and then throws there instead.`);
72
+ }
73
+ const keys = Object.keys(auth).slice(0, 5).join(', ') || '(no keys)';
74
+ throw new TypeError(`@gnldev/auth: \`auth\` is neither an AuthProvider (no \`authorize\` function) nor a {read, write} ` +
75
+ `pair (no \`read\`/\`write\` function). Got an object with: ${keys}. ` +
76
+ `If this is a credential map like { admin: { token } }, wrap it: \`roleAuth({ admin: { token } })\`. ` +
77
+ `Passing it directly would authorise every request. For no restrictions, omit \`auth\` entirely.`);
78
+ }
79
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,aAAa,CAAC,IAA8B;IAC1D,OAAO,CAAC,CAAC,IAAI,IAAK,IAAoC,CAAC,aAAa,KAAK,KAAK,CAAC;AACjF,CAAC;AAED,2IAA2I;AAC3I,MAAM,UAAU,aAAa,CAAC,EAAiB;IAC7C,OAAO;QACL,aAAa,EAAE,KAAK;QACpB,YAAY;YACV,OAAO,IAAI,CAAC,CAAC,6DAA6D;QAC5E,CAAC;QACD,KAAK,CAAC,SAAS,CAAC,EAAE,EAAE,GAAY,EAAE,GAAgB;YAChD,MAAM,EAAE,GAAG,GAAG,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC;YACtD,IAAI,CAAC,EAAE;gBAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;YAChC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC;YACzB,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;QAC7F,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,CAA+B;IACjD,OAAO,OAAQ,CAAkB,CAAC,SAAS,KAAK,UAAU,CAAC;AAC7D,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,aAAa,CAAC,IAAmC;IAC/D,IAAI,CAAC,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5B,IAAI,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAClC,MAAM,EAAE,GAAG,IAAqB,CAAC;IACjC,kGAAkG;IAClG,qGAAqG;IACrG,iGAAiG;IACjG,iGAAiG;IACjG,iDAAiD;IACjD,MAAM,KAAK,GAAI,CAAC,MAAM,EAAE,OAAO,CAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;IAC9E,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACrB,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,KAAK,UAAU,CAAC,CAAC;QAC7D,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,aAAa,CAAC,EAAE,CAAC,CAAC;QAC/C,MAAM,IAAI,SAAS,CACjB,wBAAwB,GAAG,CAAC,IAAI,CAAC,gBAAgB,CAAC,wBAAwB;YAC1E,QAAQ,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK;YAC/D,8FAA8F;YAC9F,gGAAgG;YAChG,8CAA8C,CAC/C,CAAC;IACJ,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,IAAc,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,WAAW,CAAC;IAC/E,MAAM,IAAI,SAAS,CACjB,oGAAoG;QACpG,8DAA8D,IAAI,IAAI;QACtE,sGAAsG;QACtG,iGAAiG,CAClG,CAAC;AACJ,CAAC","sourcesContent":["// Backward compatibility: wraps the old {read,write} predicate pair (StudioAuth / examples/app) into an AuthProvider.\nimport type { AuthProvider, Decision, AuthContext } from './types.js';\n\n/** Old role-based hook: read = GET (viewer), write = POST/PUT/PATCH/DELETE (admin). */\nexport interface ReadWriteAuth {\n read?: (req: Request) => boolean | Promise<boolean>;\n write?: (req: Request) => boolean | Promise<boolean>;\n}\n\n/**\n * Can this provider ever produce a principal?\n *\n * `authenticate() === null` is ambiguous: it means EITHER \"this request carried no token\" (a\n * roleAuth caller who should get 401) OR \"this provider has no principal model at all and never\n * will\" (the {read,write} pair below). A host that scopes organizations by identity has to tell\n * those apart — the first is a per-request condition, the second is a configuration that cannot\n * isolate anything, because there is no identity to bind an org to. Collapsing them makes the\n * unauthenticated caller's 401 turn into a misleading 403, or leaves the unsafe configuration open.\n *\n * Absent on hand-written providers, so `!== false` is the compatible reading: unknown means capable.\n */\nexport function bindsIdentity(auth: AuthProvider | undefined): boolean {\n return !!auth && (auth as { bindsIdentity?: boolean }).bindsIdentity !== false;\n}\n\n/** {read,write} → AuthProvider. If there's no fn in that direction, it's unrestricted (preserves the existing `!fn || fn(c)` behavior). */\nexport function fromReadWrite(rw: ReadWriteAuth): AuthProvider {\n return {\n bindsIdentity: false,\n authenticate() {\n return null; // no principal model; the decision is made in the predicate.\n },\n async authorize(_p, req: Request, ctx: AuthContext): Promise<Decision> {\n const fn = ctx.action === 'read' ? rw.read : rw.write;\n if (!fn) return { allow: true };\n const ok = await fn(req);\n return ok ? { allow: true } : { allow: false, status: ctx.action === 'write' ? 403 : 401 };\n },\n };\n}\n\nfunction isProvider(x: AuthProvider | ReadWriteAuth): x is AuthProvider {\n return typeof (x as AuthProvider).authorize === 'function';\n}\n\n/**\n * AuthProvider | {read,write} | undefined → AuthProvider | undefined (hosts reduce to a single type).\n *\n * Throws on an object that is neither, because the alternative is the worst possible answer. The\n * previous reading was \"not a provider ⇒ it must be {read,write}\", and `fromReadWrite` treats a\n * missing direction as unrestricted — so ANY unrecognised object became a provider that allows\n * everything. The realistic way to hit that is the credential map the scaffold writes:\n *\n * createRestApi({ auth: { admin: { token: 's3cret' } } }) // looks protected, allows everyone\n *\n * That exact shape shipped once already: @gnldev/studio grew `resolveConfigAuth` for it, but\n * @gnldev/server still calls this directly, so the hole stayed open on the other host. Refusing here\n * closes it for every caller and turns a silent, invisible failure into an error at startup — the one\n * moment it can still be fixed. A caller that really wants no restrictions passes `undefined`; that\n * intent is expressible and needs no guessing.\n */\nexport function normalizeAuth(auth?: AuthProvider | ReadWriteAuth): AuthProvider | undefined {\n if (!auth) return undefined;\n if (isProvider(auth)) return auth;\n const rw = auth as ReadWriteAuth;\n // Each side that is PRESENT must be a function. The check used to be `read is fn OR write is fn`,\n // which short-circuits: `{ read: fn, write: { admin: { token } } }` — one direction a predicate, the\n // other a credential map, which is what a half-finished config looks like — passed here and then\n // threw at REQUEST time, on the first write, in production. That is the silent invisible failure\n // this whole function exists to move to startup.\n const sides = (['read', 'write'] as const).filter((k) => rw[k] !== undefined);\n if (sides.length > 0) {\n const bad = sides.filter((k) => typeof rw[k] !== 'function');\n if (bad.length === 0) return fromReadWrite(rw);\n throw new TypeError(\n `@gnldev/auth: \\`auth.${bad.join('\\` and \\`auth.')}\\` must be a function ` +\n `(got ${bad.map((k) => `${k}: ${typeof rw[k]}`).join(', ')}). ` +\n `A {read, write} pair takes predicates; a credential map like { admin: { token } } has to be ` +\n `wrapped: \\`roleAuth({ admin: { token } })\\`. Left as it is, this authorises nothing until the ` +\n `first request and then throws there instead.`,\n );\n }\n const keys = Object.keys(auth as object).slice(0, 5).join(', ') || '(no keys)';\n throw new TypeError(\n `@gnldev/auth: \\`auth\\` is neither an AuthProvider (no \\`authorize\\` function) nor a {read, write} ` +\n `pair (no \\`read\\`/\\`write\\` function). Got an object with: ${keys}. ` +\n `If this is a credential map like { admin: { token } }, wrap it: \\`roleAuth({ admin: { token } })\\`. ` +\n `Passing it directly would authorise every request. For no restrictions, omit \\`auth\\` entirely.`,\n );\n}\n"]}
package/dist/gate.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { AuthProvider, Principal } from './types.js';
2
+ export interface Gate {
3
+ /** Is access allowed? true if there's no provider (opt-in: gate not set up → open; see makeGate in production). */
4
+ allow(req: Request, action: 'read' | 'write', resource?: string): Promise<boolean>;
5
+ /**
6
+ * Fine-grained variant of allow(): checks a SPECIFIC permission (e.g. 'agents:run', 'users:write').
7
+ * • No provider (auth off) → true (unchanged opt-in behavior).
8
+ * • EE / RBAC provider → the permission is passed through `AuthContext.permission` and matched against
9
+ * the principal's EFFECTIVE permissions (explicit `permissions[]` ?? role grants) — see rbac.ts.
10
+ * • Free / read-write provider → the permission is REDUCED to read/write (`:read` suffix → read, else
11
+ * write) and evaluated coarsely. This makes `allowP(req,'X:write') ≡ allow(req,'write')` and
12
+ * `allowP(req,'*:read') ≡ allow(req,'read')` in the free tier → no regression.
13
+ * A denial records its decision against the request (like allow) so `deny()` can surface status/reason.
14
+ */
15
+ allowP(req: Request, permission: string): Promise<boolean>;
16
+ /** Return the denial. If allow() attached its last decision to the context, use its status/reason. */
17
+ deny(req: Request, action: 'read' | 'write'): Response;
18
+ }
19
+ /**
20
+ * The principal authenticated during allow() (within the same request). Hosts derive the organization
21
+ * scope and audit actor from here → closed to header spoofing. Null if allow() hasn't been called yet.
22
+ */
23
+ export declare function principalOf(req: Request): Principal | null;
24
+ export interface GateOptions {
25
+ /**
26
+ * DELIBERATE permission for a providerless gate in production. Auth stays opt-in; but silent
27
+ * fail-open is impossible under NODE_ENV=production — either a provider is given or this flag is
28
+ * explicitly set to true (audit #2).
29
+ */
30
+ allowOpenAccess?: boolean;
31
+ }
32
+ export declare function makeGate(provider?: AuthProvider, opts?: GateOptions): Gate;
package/dist/gate.js ADDED
@@ -0,0 +1,122 @@
1
+ import { isCrossSiteStateChange } from './same-site.js';
2
+ import { bindsIdentity } from './adapter.js';
3
+ // Per-request state, keyed by the request itself rather than stashed as a property on it. A Request
4
+ // is somebody else's object; writing hidden fields onto it worked, but it also meant two libraries
5
+ // could pick the same key. A WeakMap cannot collide and cannot leak — the entry dies with the request.
6
+ const principals = new WeakMap();
7
+ const decisions = new WeakMap();
8
+ /**
9
+ * The principal authenticated during allow() (within the same request). Hosts derive the organization
10
+ * scope and audit actor from here → closed to header spoofing. Null if allow() hasn't been called yet.
11
+ */
12
+ export function principalOf(req) {
13
+ return principals.get(req) ?? null;
14
+ }
15
+ export function makeGate(provider, opts) {
16
+ // Fail-open audit at SETUP time: in production, a providerless gate can only be set up with the deliberate flag.
17
+ // (If left to request time, the error would blow up on the first request after deploy — an early, clear failure was preferred.)
18
+ if (!provider && process.env.NODE_ENV === 'production' && opts?.allowOpenAccess !== true) {
19
+ throw new Error('auth is required in production; for deliberately open access, set allowOpenAccess: true (see @gnldev/auth makeGate / host options)');
20
+ }
21
+ // Non-production providerless gate: warn ONCE on the first request (no silent openness), then open.
22
+ let warnedOpen = false;
23
+ let warnedCrossSite = false;
24
+ /**
25
+ * The providerless decision, in ONE place.
26
+ *
27
+ * It was written inline in `allow()` only, so `allowP()` — the fine-grained entry point, and the one
28
+ * 23 endpoints across @gnldev/server and @gnldev/studio actually call — kept an unconditional
29
+ * `return true`. Which meant the cross-site block covered the coarse path and left the specific one
30
+ * open: a page on another site could still drive every endpoint that asks for a named permission.
31
+ * Two copies of a security decision is one copy too many.
32
+ */
33
+ /**
34
+ * Is this request a cross-site state change against a surface with NO IDENTITY to ride?
35
+ *
36
+ * The condition is about identity, not about the presence of a provider — and getting that wrong
37
+ * left a hole. A legacy `{read,write}` pair IS a provider after normalizeAuth, so keying on
38
+ * `!provider` skipped the check for it; but that pair has no principal model at all (its
39
+ * authenticate() is `return null` by construction), so there is no credential a cross-site page
40
+ * could be riding, and its predicate answers on the request alone. Measured: with
41
+ * `{read:()=>true, write:()=>true}` configured, a cross-site `POST /api/retention/sweep` was
42
+ * ALLOWED. examples/app ships exactly that shape, so it was the published example that was open.
43
+ *
44
+ * `bindsIdentity(undefined)` is false, so the providerless case is covered by the same test.
45
+ */
46
+ function crossSiteWithoutIdentity(req) {
47
+ if (bindsIdentity(provider) || !isCrossSiteStateChange(req))
48
+ return false;
49
+ if (!warnedCrossSite) {
50
+ warnedCrossSite = true;
51
+ console.warn('@gnldev/auth: blocked a cross-site write to a surface with no identity to authenticate ' +
52
+ 'against. A page on another site attempted a state-changing request. Configure an auth ' +
53
+ 'provider that binds an identity if this surface is meant to be reachable by other origins.');
54
+ }
55
+ return true;
56
+ }
57
+ function openSurfaceAllows(req) {
58
+ // The warning is an INSTRUCTION ("for deliberate open access use allowOpenAccess: true"), so it
59
+ // must stop once the instruction has been followed. It did not: the flag suppressed the
60
+ // production throw but not this line, so a developer who set it kept being told to set it — which
61
+ // teaches that the flag is inert and that this package's auth warnings can be ignored. Under
62
+ // NODE_ENV=production the same flag is already accepted as the whole declaration of intent; there
63
+ // is no reason for dev to demand it twice and then not honour it.
64
+ //
65
+ // Openness WITHOUT the flag still warns, every process, exactly as before — that is the case the
66
+ // message was written for.
67
+ if (!warnedOpen && opts?.allowOpenAccess !== true && process.env.NODE_ENV !== 'production') {
68
+ warnedOpen = true;
69
+ console.warn('@gnldev/auth: no provider given → ALL endpoints are open (opt-in gate not set up). Add auth before production; for deliberate open access use allowOpenAccess: true.');
70
+ }
71
+ return true;
72
+ }
73
+ return {
74
+ async allow(req, action, resource) {
75
+ if (crossSiteWithoutIdentity(req))
76
+ return false;
77
+ if (!provider)
78
+ return openSurfaceAllows(req);
79
+ const principal = await provider.authenticate(req);
80
+ if (principal)
81
+ principals.set(req, principal);
82
+ const decision = await provider.authorize(principal, req, {
83
+ path: new URL(req.url).pathname,
84
+ method: req.method,
85
+ action,
86
+ resource,
87
+ });
88
+ if (!decision.allow)
89
+ decisions.set(req, decision);
90
+ return decision.allow;
91
+ },
92
+ async allowP(req, permission) {
93
+ if (crossSiteWithoutIdentity(req))
94
+ return false;
95
+ if (!provider)
96
+ return openSurfaceAllows(req);
97
+ const principal = await provider.authenticate(req);
98
+ if (principal)
99
+ principals.set(req, principal);
100
+ // Free-tier reduction: anything ending in ':read' is a read, everything else is a write. An RBAC
101
+ // provider ignores `action` and matches `permission` exactly; a free provider uses this reduced action.
102
+ const action = permission.endsWith(':read') ? 'read' : 'write';
103
+ const decision = await provider.authorize(principal, req, {
104
+ path: new URL(req.url).pathname,
105
+ method: req.method,
106
+ action,
107
+ permission,
108
+ });
109
+ if (!decision.allow)
110
+ decisions.set(req, decision);
111
+ return decision.allow;
112
+ },
113
+ deny(req, action) {
114
+ const d = decisions.get(req);
115
+ const denied = d && d.allow === false ? d : undefined;
116
+ const status = denied?.status ?? (action === 'write' ? 403 : 401);
117
+ const reason = denied?.reason ?? (action === 'write' ? 'unauthorized (admin required)' : 'unauthorized');
118
+ return Response.json({ error: reason }, { status });
119
+ },
120
+ };
121
+ }
122
+ //# sourceMappingURL=gate.js.map