@moon-wave/rebac 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 +21 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +205 -0
- package/package.json +25 -0
- package/src/__tests__/engine.test.ts +128 -0
- package/src/engine.ts +278 -0
- package/src/index.ts +3 -0
- package/src/schema.ts +28 -0
- package/src/types.ts +43 -0
- package/tsconfig.json +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 linhhang1412
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
type ObjectType = 'user' | 'organization' | 'agent' | 'session';
|
|
2
|
+
type RelationType = 'owner' | 'editor' | 'viewer' | 'member' | 'admin';
|
|
3
|
+
interface ReBACTuple {
|
|
4
|
+
objectType: ObjectType;
|
|
5
|
+
objectId: string;
|
|
6
|
+
relation: RelationType;
|
|
7
|
+
subjectType: ObjectType;
|
|
8
|
+
subjectId: string;
|
|
9
|
+
/** For group references, e.g. "member" means subject is "organization:id#member" */
|
|
10
|
+
subjectRelation?: RelationType;
|
|
11
|
+
}
|
|
12
|
+
interface ReBACTupleRecord extends ReBACTuple {
|
|
13
|
+
id: number;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
interface ReBACUser {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
email: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
/** Present only when freshly generated — never returned by listUsers() */
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
}
|
|
24
|
+
interface CheckResult {
|
|
25
|
+
allowed: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Minimal D1 binding interface (compatible with Cloudflare Workers D1) */
|
|
28
|
+
interface D1DatabaseBinding {
|
|
29
|
+
prepare(query: string): {
|
|
30
|
+
bind(...values: unknown[]): {
|
|
31
|
+
all<T = Record<string, unknown>>(): Promise<{
|
|
32
|
+
results: T[];
|
|
33
|
+
}>;
|
|
34
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
35
|
+
run(): Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
exec(query: string): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare const REBAC_MIGRATION: string;
|
|
42
|
+
/** Run after REBAC_MIGRATION to add api_key to existing installations. */
|
|
43
|
+
declare const REBAC_MIGRATION_V2 = "ALTER TABLE rebac_users ADD COLUMN api_key TEXT UNIQUE";
|
|
44
|
+
|
|
45
|
+
declare class D1ReBAC {
|
|
46
|
+
private db;
|
|
47
|
+
constructor(db: D1DatabaseBinding);
|
|
48
|
+
migrate(): Promise<void>;
|
|
49
|
+
writeTuple(tuple: ReBACTuple): Promise<void>;
|
|
50
|
+
deleteTuple(tuple: ReBACTuple): Promise<void>;
|
|
51
|
+
listTuples(filter?: {
|
|
52
|
+
objectType?: ObjectType;
|
|
53
|
+
objectId?: string;
|
|
54
|
+
}): Promise<ReBACTupleRecord[]>;
|
|
55
|
+
/**
|
|
56
|
+
* Check if subject has `relation` on object.
|
|
57
|
+
* Supports 1-level group expansion:
|
|
58
|
+
* If a tuple says (object, relation) → (org, orgId, #member),
|
|
59
|
+
* we check if (org, member) → (subject) also exists.
|
|
60
|
+
*/
|
|
61
|
+
check(subjectType: ObjectType, subjectId: string, relation: RelationType, objectType: ObjectType, objectId: string): Promise<boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* Convenience: returns true if user has owner OR editor relation on agent.
|
|
64
|
+
* Use before allowing an agent run.
|
|
65
|
+
*/
|
|
66
|
+
canRunAgent(userId: string, agentName: string): Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* Convenience: returns true if user has any relation (owner/editor/viewer) on agent.
|
|
69
|
+
*/
|
|
70
|
+
canViewAgent(userId: string, agentName: string): Promise<boolean>;
|
|
71
|
+
listSubjectsForObject(objectType: ObjectType, objectId: string, relation: RelationType): Promise<Array<{
|
|
72
|
+
subjectType: string;
|
|
73
|
+
subjectId: string;
|
|
74
|
+
subjectRelation?: string;
|
|
75
|
+
}>>;
|
|
76
|
+
listObjectsForSubject(subjectType: ObjectType, subjectId: string, relation: RelationType, targetObjectType: ObjectType): Promise<string[]>;
|
|
77
|
+
createUser(id: string, name: string, email: string): Promise<void>;
|
|
78
|
+
listUsers(): Promise<ReBACUser[]>;
|
|
79
|
+
getUser(id: string): Promise<ReBACUser | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Look up a user by their API key.
|
|
82
|
+
* Returns null if the key is invalid or not found.
|
|
83
|
+
*/
|
|
84
|
+
getUserByApiKey(apiKey: string): Promise<ReBACUser | null>;
|
|
85
|
+
/**
|
|
86
|
+
* Generate a new random API key for a user.
|
|
87
|
+
* Returns the new key (only shown once — not retrievable later).
|
|
88
|
+
*/
|
|
89
|
+
generateApiKey(userId: string): Promise<string>;
|
|
90
|
+
/** Revoke the API key for a user. */
|
|
91
|
+
revokeApiKey(userId: string): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { type CheckResult, type D1DatabaseBinding, D1ReBAC, type ObjectType, REBAC_MIGRATION, REBAC_MIGRATION_V2, type ReBACTuple, type ReBACTupleRecord, type ReBACUser, type RelationType };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// src/schema.ts
|
|
2
|
+
var REBAC_MIGRATION = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS rebac_users (
|
|
4
|
+
id TEXT PRIMARY KEY,
|
|
5
|
+
name TEXT NOT NULL,
|
|
6
|
+
email TEXT UNIQUE NOT NULL,
|
|
7
|
+
api_key TEXT UNIQUE,
|
|
8
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
CREATE TABLE IF NOT EXISTS rebac_tuples (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
object_type TEXT NOT NULL,
|
|
14
|
+
object_id TEXT NOT NULL,
|
|
15
|
+
relation TEXT NOT NULL,
|
|
16
|
+
subject_type TEXT NOT NULL,
|
|
17
|
+
subject_id TEXT NOT NULL,
|
|
18
|
+
subject_relation TEXT,
|
|
19
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
20
|
+
UNIQUE(object_type, object_id, relation, subject_type, subject_id, COALESCE(subject_relation, ''))
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_rebac_object ON rebac_tuples(object_type, object_id, relation);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_rebac_subject ON rebac_tuples(subject_type, subject_id);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_rebac_apikey ON rebac_users(api_key);
|
|
26
|
+
`.trim();
|
|
27
|
+
var REBAC_MIGRATION_V2 = `ALTER TABLE rebac_users ADD COLUMN api_key TEXT UNIQUE`;
|
|
28
|
+
|
|
29
|
+
// src/engine.ts
|
|
30
|
+
var D1ReBAC = class {
|
|
31
|
+
constructor(db) {
|
|
32
|
+
this.db = db;
|
|
33
|
+
}
|
|
34
|
+
db;
|
|
35
|
+
async migrate() {
|
|
36
|
+
const statements = REBAC_MIGRATION.split(";\n").map((s) => s.trim()).filter(Boolean);
|
|
37
|
+
for (const sql of statements) {
|
|
38
|
+
await this.db.prepare(sql + ";").bind().run();
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await this.db.prepare(REBAC_MIGRATION_V2).bind().run();
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async writeTuple(tuple) {
|
|
46
|
+
await this.db.prepare(
|
|
47
|
+
`INSERT OR IGNORE INTO rebac_tuples
|
|
48
|
+
(object_type, object_id, relation, subject_type, subject_id, subject_relation)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
50
|
+
).bind(tuple.objectType, tuple.objectId, tuple.relation, tuple.subjectType, tuple.subjectId, tuple.subjectRelation ?? null).run();
|
|
51
|
+
}
|
|
52
|
+
async deleteTuple(tuple) {
|
|
53
|
+
await this.db.prepare(
|
|
54
|
+
`DELETE FROM rebac_tuples
|
|
55
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
56
|
+
AND subject_type = ? AND subject_id = ?
|
|
57
|
+
AND COALESCE(subject_relation, '') = COALESCE(?, '')`
|
|
58
|
+
).bind(tuple.objectType, tuple.objectId, tuple.relation, tuple.subjectType, tuple.subjectId, tuple.subjectRelation ?? null).run();
|
|
59
|
+
}
|
|
60
|
+
async listTuples(filter) {
|
|
61
|
+
let sql = "SELECT * FROM rebac_tuples";
|
|
62
|
+
const bindings = [];
|
|
63
|
+
if (filter?.objectType && filter?.objectId) {
|
|
64
|
+
sql += " WHERE object_type = ? AND object_id = ?";
|
|
65
|
+
bindings.push(filter.objectType, filter.objectId);
|
|
66
|
+
} else if (filter?.objectType) {
|
|
67
|
+
sql += " WHERE object_type = ?";
|
|
68
|
+
bindings.push(filter.objectType);
|
|
69
|
+
}
|
|
70
|
+
sql += " ORDER BY created_at DESC";
|
|
71
|
+
const { results } = await this.db.prepare(sql).bind(...bindings).all();
|
|
72
|
+
return results.map(rowToTuple);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if subject has `relation` on object.
|
|
76
|
+
* Supports 1-level group expansion:
|
|
77
|
+
* If a tuple says (object, relation) → (org, orgId, #member),
|
|
78
|
+
* we check if (org, member) → (subject) also exists.
|
|
79
|
+
*/
|
|
80
|
+
async check(subjectType, subjectId, relation, objectType, objectId) {
|
|
81
|
+
const direct = await this.db.prepare(
|
|
82
|
+
`SELECT 1 FROM rebac_tuples
|
|
83
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
84
|
+
AND subject_type = ? AND subject_id = ? AND subject_relation IS NULL
|
|
85
|
+
LIMIT 1`
|
|
86
|
+
).bind(objectType, objectId, relation, subjectType, subjectId).first();
|
|
87
|
+
if (direct) return true;
|
|
88
|
+
const groupTuples = await this.db.prepare(
|
|
89
|
+
`SELECT subject_type, subject_id, subject_relation
|
|
90
|
+
FROM rebac_tuples
|
|
91
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
92
|
+
AND subject_relation IS NOT NULL`
|
|
93
|
+
).bind(objectType, objectId, relation).all();
|
|
94
|
+
for (const group of groupTuples.results) {
|
|
95
|
+
const memberCheck = await this.db.prepare(
|
|
96
|
+
`SELECT 1 FROM rebac_tuples
|
|
97
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
98
|
+
AND subject_type = ? AND subject_id = ? AND subject_relation IS NULL
|
|
99
|
+
LIMIT 1`
|
|
100
|
+
).bind(group.subject_type, group.subject_id, group.subject_relation, subjectType, subjectId).first();
|
|
101
|
+
if (memberCheck) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Convenience: returns true if user has owner OR editor relation on agent.
|
|
107
|
+
* Use before allowing an agent run.
|
|
108
|
+
*/
|
|
109
|
+
async canRunAgent(userId, agentName) {
|
|
110
|
+
const [isOwner, isEditor] = await Promise.all([
|
|
111
|
+
this.check("user", userId, "owner", "agent", agentName),
|
|
112
|
+
this.check("user", userId, "editor", "agent", agentName)
|
|
113
|
+
]);
|
|
114
|
+
return isOwner || isEditor;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Convenience: returns true if user has any relation (owner/editor/viewer) on agent.
|
|
118
|
+
*/
|
|
119
|
+
async canViewAgent(userId, agentName) {
|
|
120
|
+
const [isOwner, isEditor, isViewer] = await Promise.all([
|
|
121
|
+
this.check("user", userId, "owner", "agent", agentName),
|
|
122
|
+
this.check("user", userId, "editor", "agent", agentName),
|
|
123
|
+
this.check("user", userId, "viewer", "agent", agentName)
|
|
124
|
+
]);
|
|
125
|
+
return isOwner || isEditor || isViewer;
|
|
126
|
+
}
|
|
127
|
+
async listSubjectsForObject(objectType, objectId, relation) {
|
|
128
|
+
const { results } = await this.db.prepare(
|
|
129
|
+
`SELECT subject_type, subject_id, subject_relation
|
|
130
|
+
FROM rebac_tuples
|
|
131
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
132
|
+
ORDER BY created_at DESC`
|
|
133
|
+
).bind(objectType, objectId, relation).all();
|
|
134
|
+
return results.map((r) => ({
|
|
135
|
+
subjectType: r.subject_type,
|
|
136
|
+
subjectId: r.subject_id,
|
|
137
|
+
...r.subject_relation ? { subjectRelation: r.subject_relation } : {}
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
async listObjectsForSubject(subjectType, subjectId, relation, targetObjectType) {
|
|
141
|
+
const { results } = await this.db.prepare(
|
|
142
|
+
`SELECT object_id FROM rebac_tuples
|
|
143
|
+
WHERE subject_type = ? AND subject_id = ? AND relation = ? AND object_type = ?
|
|
144
|
+
ORDER BY created_at DESC`
|
|
145
|
+
).bind(subjectType, subjectId, relation, targetObjectType).all();
|
|
146
|
+
return results.map((r) => r.object_id);
|
|
147
|
+
}
|
|
148
|
+
async createUser(id, name, email) {
|
|
149
|
+
await this.db.prepare(`INSERT INTO rebac_users (id, name, email) VALUES (?, ?, ?)`).bind(id, name, email).run();
|
|
150
|
+
}
|
|
151
|
+
async listUsers() {
|
|
152
|
+
const { results } = await this.db.prepare("SELECT id, name, email, created_at FROM rebac_users ORDER BY created_at DESC").bind().all();
|
|
153
|
+
return results.map(rowToUser);
|
|
154
|
+
}
|
|
155
|
+
async getUser(id) {
|
|
156
|
+
const row = await this.db.prepare("SELECT id, name, email, created_at FROM rebac_users WHERE id = ?").bind(id).first();
|
|
157
|
+
return row ? rowToUser(row) : null;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Look up a user by their API key.
|
|
161
|
+
* Returns null if the key is invalid or not found.
|
|
162
|
+
*/
|
|
163
|
+
async getUserByApiKey(apiKey) {
|
|
164
|
+
const row = await this.db.prepare("SELECT id, name, email, created_at FROM rebac_users WHERE api_key = ? LIMIT 1").bind(apiKey).first();
|
|
165
|
+
return row ? rowToUser(row) : null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Generate a new random API key for a user.
|
|
169
|
+
* Returns the new key (only shown once — not retrievable later).
|
|
170
|
+
*/
|
|
171
|
+
async generateApiKey(userId) {
|
|
172
|
+
const key = generateRandomKey();
|
|
173
|
+
await this.db.prepare("UPDATE rebac_users SET api_key = ? WHERE id = ?").bind(key, userId).run();
|
|
174
|
+
return key;
|
|
175
|
+
}
|
|
176
|
+
/** Revoke the API key for a user. */
|
|
177
|
+
async revokeApiKey(userId) {
|
|
178
|
+
await this.db.prepare("UPDATE rebac_users SET api_key = NULL WHERE id = ?").bind(userId).run();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
function rowToTuple(row) {
|
|
182
|
+
return {
|
|
183
|
+
id: row.id,
|
|
184
|
+
objectType: row.object_type,
|
|
185
|
+
objectId: row.object_id,
|
|
186
|
+
relation: row.relation,
|
|
187
|
+
subjectType: row.subject_type,
|
|
188
|
+
subjectId: row.subject_id,
|
|
189
|
+
...row.subject_relation ? { subjectRelation: row.subject_relation } : {},
|
|
190
|
+
createdAt: row.created_at
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function rowToUser(row) {
|
|
194
|
+
return { id: row.id, name: row.name, email: row.email, createdAt: row.created_at };
|
|
195
|
+
}
|
|
196
|
+
function generateRandomKey() {
|
|
197
|
+
const bytes = new Uint8Array(32);
|
|
198
|
+
crypto.getRandomValues(bytes);
|
|
199
|
+
return "mwk_" + Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
200
|
+
}
|
|
201
|
+
export {
|
|
202
|
+
D1ReBAC,
|
|
203
|
+
REBAC_MIGRATION,
|
|
204
|
+
REBAC_MIGRATION_V2
|
|
205
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moon-wave/rebac",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Relationship-Based Access Control (ReBAC) engine for moon-wave — Zanzibar-style tuple model backed by Cloudflare D1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"tsup": "^8.0.0",
|
|
14
|
+
"typescript": "^5.5.0",
|
|
15
|
+
"vitest": "^2.0.0"
|
|
16
|
+
},
|
|
17
|
+
"author": "linhhang1412",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"dev": "tsup src/index.ts --format esm --dts --watch"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { D1ReBAC } from '../engine';
|
|
3
|
+
import type { D1DatabaseBinding } from '../types';
|
|
4
|
+
|
|
5
|
+
// In-memory mock for D1
|
|
6
|
+
function createMockD1(): D1DatabaseBinding {
|
|
7
|
+
const tables: Record<string, Record<string, unknown>[]> = {
|
|
8
|
+
rebac_users: [],
|
|
9
|
+
rebac_tuples: [],
|
|
10
|
+
};
|
|
11
|
+
let autoId = 1;
|
|
12
|
+
|
|
13
|
+
function matchRow(row: Record<string, unknown>, where: Record<string, unknown>): boolean {
|
|
14
|
+
return Object.entries(where).every(([k, v]) => {
|
|
15
|
+
if (v === null) return row[k] === null || row[k] === undefined;
|
|
16
|
+
return row[k] === v;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
prepare(sql: string) {
|
|
22
|
+
return {
|
|
23
|
+
bind(...values: unknown[]) {
|
|
24
|
+
return {
|
|
25
|
+
async all<T>() {
|
|
26
|
+
if (sql.includes('FROM rebac_tuples') && !sql.includes('INSERT') && !sql.includes('DELETE')) {
|
|
27
|
+
const whereMatch = sql.match(/WHERE (.+?)(?:\s+ORDER|\s+LIMIT|$)/s);
|
|
28
|
+
if (!whereMatch) return { results: tables.rebac_tuples as T[] };
|
|
29
|
+
|
|
30
|
+
const conditions = whereMatch[1];
|
|
31
|
+
let results = tables.rebac_tuples.filter(row => {
|
|
32
|
+
const parts = conditions.split(' AND ').map(s => s.trim());
|
|
33
|
+
return parts.every((part, idx) => {
|
|
34
|
+
if (part.includes('subject_relation IS NOT NULL')) return row.subject_relation != null;
|
|
35
|
+
if (part.includes('subject_relation IS NULL')) return row.subject_relation == null;
|
|
36
|
+
if (part.includes('= ?')) return row[part.split(' = ?')[0].trim().replace(/^.+\./, '')] === values[idx];
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return { results: results as T[] };
|
|
41
|
+
}
|
|
42
|
+
if (sql.includes('FROM rebac_users')) {
|
|
43
|
+
return { results: tables.rebac_users as T[] };
|
|
44
|
+
}
|
|
45
|
+
return { results: [] as T[] };
|
|
46
|
+
},
|
|
47
|
+
async first<T>() {
|
|
48
|
+
if (sql.includes('SELECT 1 FROM rebac_tuples')) {
|
|
49
|
+
const [ot, oi, rel, st, si] = values as string[];
|
|
50
|
+
const found = tables.rebac_tuples.find(r =>
|
|
51
|
+
r.object_type === ot && r.object_id === oi && r.relation === rel &&
|
|
52
|
+
r.subject_type === st && r.subject_id === si && r.subject_relation == null
|
|
53
|
+
);
|
|
54
|
+
return (found ? { 1: 1 } : null) as T;
|
|
55
|
+
}
|
|
56
|
+
if (sql.includes('FROM rebac_users WHERE id')) {
|
|
57
|
+
const found = tables.rebac_users.find(r => r.id === values[0]);
|
|
58
|
+
return (found ?? null) as T;
|
|
59
|
+
}
|
|
60
|
+
return null as T;
|
|
61
|
+
},
|
|
62
|
+
async run() {
|
|
63
|
+
if (sql.startsWith('INSERT OR IGNORE INTO rebac_tuples')) {
|
|
64
|
+
const [ot, oi, rel, st, si, sr] = values;
|
|
65
|
+
const existing = tables.rebac_tuples.find(r =>
|
|
66
|
+
r.object_type === ot && r.object_id === oi && r.relation === rel &&
|
|
67
|
+
r.subject_type === st && r.subject_id === si &&
|
|
68
|
+
(r.subject_relation ?? null) === (sr ?? null)
|
|
69
|
+
);
|
|
70
|
+
if (!existing) {
|
|
71
|
+
tables.rebac_tuples.push({ id: autoId++, object_type: ot, object_id: oi, relation: rel, subject_type: st, subject_id: si, subject_relation: sr ?? null, created_at: new Date().toISOString() });
|
|
72
|
+
}
|
|
73
|
+
} else if (sql.startsWith('DELETE FROM rebac_tuples')) {
|
|
74
|
+
const [ot, oi, rel, st, si] = values;
|
|
75
|
+
tables.rebac_tuples = tables.rebac_tuples.filter(r =>
|
|
76
|
+
!(r.object_type === ot && r.object_id === oi && r.relation === rel && r.subject_type === st && r.subject_id === si)
|
|
77
|
+
);
|
|
78
|
+
} else if (sql.startsWith('INSERT INTO rebac_users')) {
|
|
79
|
+
const [id, name, email] = values;
|
|
80
|
+
tables.rebac_users.push({ id, name, email, created_at: new Date().toISOString() });
|
|
81
|
+
} else if (sql.trim().endsWith(';') || sql.includes('CREATE TABLE') || sql.includes('CREATE INDEX')) {
|
|
82
|
+
// DDL — no-op in mock
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
async exec() {},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('D1ReBAC', () => {
|
|
94
|
+
let rebac: D1ReBAC;
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
rebac = new D1ReBAC(createMockD1());
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns false when no tuples exist', async () => {
|
|
101
|
+
const allowed = await rebac.check('user', 'alice', 'owner', 'agent', 'summarizer');
|
|
102
|
+
expect(allowed).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('grants direct ownership', async () => {
|
|
106
|
+
await rebac.writeTuple({ objectType: 'agent', objectId: 'summarizer', relation: 'owner', subjectType: 'user', subjectId: 'alice' });
|
|
107
|
+
expect(await rebac.check('user', 'alice', 'owner', 'agent', 'summarizer')).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('denies mismatched relation', async () => {
|
|
111
|
+
await rebac.writeTuple({ objectType: 'agent', objectId: 'summarizer', relation: 'viewer', subjectType: 'user', subjectId: 'alice' });
|
|
112
|
+
expect(await rebac.check('user', 'alice', 'owner', 'agent', 'summarizer')).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('denies after tuple deletion', async () => {
|
|
116
|
+
const tuple = { objectType: 'agent' as const, objectId: 'summarizer', relation: 'owner' as const, subjectType: 'user' as const, subjectId: 'alice' };
|
|
117
|
+
await rebac.writeTuple(tuple);
|
|
118
|
+
await rebac.deleteTuple(tuple);
|
|
119
|
+
expect(await rebac.check('user', 'alice', 'owner', 'agent', 'summarizer')).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('creates and lists users', async () => {
|
|
123
|
+
await rebac.createUser('u1', 'Alice', 'alice@example.com');
|
|
124
|
+
const users = await rebac.listUsers();
|
|
125
|
+
expect(users).toHaveLength(1);
|
|
126
|
+
expect(users[0].name).toBe('Alice');
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { D1DatabaseBinding, ObjectType, ReBACTuple, ReBACTupleRecord, ReBACUser, RelationType } from './types';
|
|
2
|
+
import { REBAC_MIGRATION, REBAC_MIGRATION_V2 } from './schema';
|
|
3
|
+
|
|
4
|
+
interface TupleRow {
|
|
5
|
+
id: number;
|
|
6
|
+
object_type: string;
|
|
7
|
+
object_id: string;
|
|
8
|
+
relation: string;
|
|
9
|
+
subject_type: string;
|
|
10
|
+
subject_id: string;
|
|
11
|
+
subject_relation: string | null;
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface UserRow {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
email: string;
|
|
19
|
+
api_key: string | null;
|
|
20
|
+
created_at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class D1ReBAC {
|
|
24
|
+
constructor(private db: D1DatabaseBinding) {}
|
|
25
|
+
|
|
26
|
+
async migrate(): Promise<void> {
|
|
27
|
+
const statements = REBAC_MIGRATION.split(';\n').map(s => s.trim()).filter(Boolean);
|
|
28
|
+
for (const sql of statements) {
|
|
29
|
+
await this.db.prepare(sql + ';').bind().run();
|
|
30
|
+
}
|
|
31
|
+
// Add api_key column to existing installations — ignore error if already exists
|
|
32
|
+
try {
|
|
33
|
+
await this.db.prepare(REBAC_MIGRATION_V2).bind().run();
|
|
34
|
+
} catch {
|
|
35
|
+
// Column already exists — safe to ignore
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async writeTuple(tuple: ReBACTuple): Promise<void> {
|
|
40
|
+
await this.db
|
|
41
|
+
.prepare(
|
|
42
|
+
`INSERT OR IGNORE INTO rebac_tuples
|
|
43
|
+
(object_type, object_id, relation, subject_type, subject_id, subject_relation)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
45
|
+
)
|
|
46
|
+
.bind(tuple.objectType, tuple.objectId, tuple.relation, tuple.subjectType, tuple.subjectId, tuple.subjectRelation ?? null)
|
|
47
|
+
.run();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async deleteTuple(tuple: ReBACTuple): Promise<void> {
|
|
51
|
+
await this.db
|
|
52
|
+
.prepare(
|
|
53
|
+
`DELETE FROM rebac_tuples
|
|
54
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
55
|
+
AND subject_type = ? AND subject_id = ?
|
|
56
|
+
AND COALESCE(subject_relation, '') = COALESCE(?, '')`,
|
|
57
|
+
)
|
|
58
|
+
.bind(tuple.objectType, tuple.objectId, tuple.relation, tuple.subjectType, tuple.subjectId, tuple.subjectRelation ?? null)
|
|
59
|
+
.run();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async listTuples(filter?: { objectType?: ObjectType; objectId?: string }): Promise<ReBACTupleRecord[]> {
|
|
63
|
+
let sql = 'SELECT * FROM rebac_tuples';
|
|
64
|
+
const bindings: unknown[] = [];
|
|
65
|
+
|
|
66
|
+
if (filter?.objectType && filter?.objectId) {
|
|
67
|
+
sql += ' WHERE object_type = ? AND object_id = ?';
|
|
68
|
+
bindings.push(filter.objectType, filter.objectId);
|
|
69
|
+
} else if (filter?.objectType) {
|
|
70
|
+
sql += ' WHERE object_type = ?';
|
|
71
|
+
bindings.push(filter.objectType);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
sql += ' ORDER BY created_at DESC';
|
|
75
|
+
|
|
76
|
+
const { results } = await this.db.prepare(sql).bind(...bindings).all<TupleRow>();
|
|
77
|
+
return results.map(rowToTuple);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if subject has `relation` on object.
|
|
82
|
+
* Supports 1-level group expansion:
|
|
83
|
+
* If a tuple says (object, relation) → (org, orgId, #member),
|
|
84
|
+
* we check if (org, member) → (subject) also exists.
|
|
85
|
+
*/
|
|
86
|
+
async check(
|
|
87
|
+
subjectType: ObjectType,
|
|
88
|
+
subjectId: string,
|
|
89
|
+
relation: RelationType,
|
|
90
|
+
objectType: ObjectType,
|
|
91
|
+
objectId: string,
|
|
92
|
+
): Promise<boolean> {
|
|
93
|
+
// Direct match
|
|
94
|
+
const direct = await this.db
|
|
95
|
+
.prepare(
|
|
96
|
+
`SELECT 1 FROM rebac_tuples
|
|
97
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
98
|
+
AND subject_type = ? AND subject_id = ? AND subject_relation IS NULL
|
|
99
|
+
LIMIT 1`,
|
|
100
|
+
)
|
|
101
|
+
.bind(objectType, objectId, relation, subjectType, subjectId)
|
|
102
|
+
.first<{ 1: number }>();
|
|
103
|
+
|
|
104
|
+
if (direct) return true;
|
|
105
|
+
|
|
106
|
+
// Group expansion: find tuples where subject is a group reference
|
|
107
|
+
// e.g., (agent:X, editor) → (organization:acme, #member)
|
|
108
|
+
// then check if (organization:acme, member) → (user:alice)
|
|
109
|
+
const groupTuples = await this.db
|
|
110
|
+
.prepare(
|
|
111
|
+
`SELECT subject_type, subject_id, subject_relation
|
|
112
|
+
FROM rebac_tuples
|
|
113
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
114
|
+
AND subject_relation IS NOT NULL`,
|
|
115
|
+
)
|
|
116
|
+
.bind(objectType, objectId, relation)
|
|
117
|
+
.all<{ subject_type: string; subject_id: string; subject_relation: string }>();
|
|
118
|
+
|
|
119
|
+
for (const group of groupTuples.results) {
|
|
120
|
+
const memberCheck = await this.db
|
|
121
|
+
.prepare(
|
|
122
|
+
`SELECT 1 FROM rebac_tuples
|
|
123
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
124
|
+
AND subject_type = ? AND subject_id = ? AND subject_relation IS NULL
|
|
125
|
+
LIMIT 1`,
|
|
126
|
+
)
|
|
127
|
+
.bind(group.subject_type, group.subject_id, group.subject_relation, subjectType, subjectId)
|
|
128
|
+
.first<{ 1: number }>();
|
|
129
|
+
|
|
130
|
+
if (memberCheck) return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Convenience: returns true if user has owner OR editor relation on agent.
|
|
138
|
+
* Use before allowing an agent run.
|
|
139
|
+
*/
|
|
140
|
+
async canRunAgent(userId: string, agentName: string): Promise<boolean> {
|
|
141
|
+
const [isOwner, isEditor] = await Promise.all([
|
|
142
|
+
this.check('user', userId, 'owner', 'agent', agentName),
|
|
143
|
+
this.check('user', userId, 'editor', 'agent', agentName),
|
|
144
|
+
]);
|
|
145
|
+
return isOwner || isEditor;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Convenience: returns true if user has any relation (owner/editor/viewer) on agent.
|
|
150
|
+
*/
|
|
151
|
+
async canViewAgent(userId: string, agentName: string): Promise<boolean> {
|
|
152
|
+
const [isOwner, isEditor, isViewer] = await Promise.all([
|
|
153
|
+
this.check('user', userId, 'owner', 'agent', agentName),
|
|
154
|
+
this.check('user', userId, 'editor', 'agent', agentName),
|
|
155
|
+
this.check('user', userId, 'viewer', 'agent', agentName),
|
|
156
|
+
]);
|
|
157
|
+
return isOwner || isEditor || isViewer;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async listSubjectsForObject(
|
|
161
|
+
objectType: ObjectType,
|
|
162
|
+
objectId: string,
|
|
163
|
+
relation: RelationType,
|
|
164
|
+
): Promise<Array<{ subjectType: string; subjectId: string; subjectRelation?: string }>> {
|
|
165
|
+
const { results } = await this.db
|
|
166
|
+
.prepare(
|
|
167
|
+
`SELECT subject_type, subject_id, subject_relation
|
|
168
|
+
FROM rebac_tuples
|
|
169
|
+
WHERE object_type = ? AND object_id = ? AND relation = ?
|
|
170
|
+
ORDER BY created_at DESC`,
|
|
171
|
+
)
|
|
172
|
+
.bind(objectType, objectId, relation)
|
|
173
|
+
.all<{ subject_type: string; subject_id: string; subject_relation: string | null }>();
|
|
174
|
+
|
|
175
|
+
return results.map(r => ({
|
|
176
|
+
subjectType: r.subject_type,
|
|
177
|
+
subjectId: r.subject_id,
|
|
178
|
+
...(r.subject_relation ? { subjectRelation: r.subject_relation } : {}),
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async listObjectsForSubject(
|
|
183
|
+
subjectType: ObjectType,
|
|
184
|
+
subjectId: string,
|
|
185
|
+
relation: RelationType,
|
|
186
|
+
targetObjectType: ObjectType,
|
|
187
|
+
): Promise<string[]> {
|
|
188
|
+
const { results } = await this.db
|
|
189
|
+
.prepare(
|
|
190
|
+
`SELECT object_id FROM rebac_tuples
|
|
191
|
+
WHERE subject_type = ? AND subject_id = ? AND relation = ? AND object_type = ?
|
|
192
|
+
ORDER BY created_at DESC`,
|
|
193
|
+
)
|
|
194
|
+
.bind(subjectType, subjectId, relation, targetObjectType)
|
|
195
|
+
.all<{ object_id: string }>();
|
|
196
|
+
|
|
197
|
+
return results.map(r => r.object_id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async createUser(id: string, name: string, email: string): Promise<void> {
|
|
201
|
+
await this.db
|
|
202
|
+
.prepare(`INSERT INTO rebac_users (id, name, email) VALUES (?, ?, ?)`)
|
|
203
|
+
.bind(id, name, email)
|
|
204
|
+
.run();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async listUsers(): Promise<ReBACUser[]> {
|
|
208
|
+
const { results } = await this.db
|
|
209
|
+
.prepare('SELECT id, name, email, created_at FROM rebac_users ORDER BY created_at DESC')
|
|
210
|
+
.bind()
|
|
211
|
+
.all<UserRow>();
|
|
212
|
+
return results.map(rowToUser);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async getUser(id: string): Promise<ReBACUser | null> {
|
|
216
|
+
const row = await this.db
|
|
217
|
+
.prepare('SELECT id, name, email, created_at FROM rebac_users WHERE id = ?')
|
|
218
|
+
.bind(id)
|
|
219
|
+
.first<UserRow>();
|
|
220
|
+
return row ? rowToUser(row) : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Look up a user by their API key.
|
|
225
|
+
* Returns null if the key is invalid or not found.
|
|
226
|
+
*/
|
|
227
|
+
async getUserByApiKey(apiKey: string): Promise<ReBACUser | null> {
|
|
228
|
+
const row = await this.db
|
|
229
|
+
.prepare('SELECT id, name, email, created_at FROM rebac_users WHERE api_key = ? LIMIT 1')
|
|
230
|
+
.bind(apiKey)
|
|
231
|
+
.first<UserRow>();
|
|
232
|
+
return row ? rowToUser(row) : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Generate a new random API key for a user.
|
|
237
|
+
* Returns the new key (only shown once — not retrievable later).
|
|
238
|
+
*/
|
|
239
|
+
async generateApiKey(userId: string): Promise<string> {
|
|
240
|
+
const key = generateRandomKey();
|
|
241
|
+
await this.db
|
|
242
|
+
.prepare('UPDATE rebac_users SET api_key = ? WHERE id = ?')
|
|
243
|
+
.bind(key, userId)
|
|
244
|
+
.run();
|
|
245
|
+
return key;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Revoke the API key for a user. */
|
|
249
|
+
async revokeApiKey(userId: string): Promise<void> {
|
|
250
|
+
await this.db
|
|
251
|
+
.prepare('UPDATE rebac_users SET api_key = NULL WHERE id = ?')
|
|
252
|
+
.bind(userId)
|
|
253
|
+
.run();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function rowToTuple(row: TupleRow): ReBACTupleRecord {
|
|
258
|
+
return {
|
|
259
|
+
id: row.id,
|
|
260
|
+
objectType: row.object_type as ObjectType,
|
|
261
|
+
objectId: row.object_id,
|
|
262
|
+
relation: row.relation as RelationType,
|
|
263
|
+
subjectType: row.subject_type as ObjectType,
|
|
264
|
+
subjectId: row.subject_id,
|
|
265
|
+
...(row.subject_relation ? { subjectRelation: row.subject_relation as RelationType } : {}),
|
|
266
|
+
createdAt: row.created_at,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function rowToUser(row: UserRow): ReBACUser {
|
|
271
|
+
return { id: row.id, name: row.name, email: row.email, createdAt: row.created_at };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function generateRandomKey(): string {
|
|
275
|
+
const bytes = new Uint8Array(32);
|
|
276
|
+
crypto.getRandomValues(bytes);
|
|
277
|
+
return 'mwk_' + Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
278
|
+
}
|
package/src/index.ts
ADDED
package/src/schema.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const REBAC_MIGRATION = `
|
|
2
|
+
CREATE TABLE IF NOT EXISTS rebac_users (
|
|
3
|
+
id TEXT PRIMARY KEY,
|
|
4
|
+
name TEXT NOT NULL,
|
|
5
|
+
email TEXT UNIQUE NOT NULL,
|
|
6
|
+
api_key TEXT UNIQUE,
|
|
7
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
CREATE TABLE IF NOT EXISTS rebac_tuples (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
object_type TEXT NOT NULL,
|
|
13
|
+
object_id TEXT NOT NULL,
|
|
14
|
+
relation TEXT NOT NULL,
|
|
15
|
+
subject_type TEXT NOT NULL,
|
|
16
|
+
subject_id TEXT NOT NULL,
|
|
17
|
+
subject_relation TEXT,
|
|
18
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
19
|
+
UNIQUE(object_type, object_id, relation, subject_type, subject_id, COALESCE(subject_relation, ''))
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_rebac_object ON rebac_tuples(object_type, object_id, relation);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_rebac_subject ON rebac_tuples(subject_type, subject_id);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_rebac_apikey ON rebac_users(api_key);
|
|
25
|
+
`.trim();
|
|
26
|
+
|
|
27
|
+
/** Run after REBAC_MIGRATION to add api_key to existing installations. */
|
|
28
|
+
export const REBAC_MIGRATION_V2 = `ALTER TABLE rebac_users ADD COLUMN api_key TEXT UNIQUE`;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type ObjectType = 'user' | 'organization' | 'agent' | 'session';
|
|
2
|
+
|
|
3
|
+
export type RelationType = 'owner' | 'editor' | 'viewer' | 'member' | 'admin';
|
|
4
|
+
|
|
5
|
+
export interface ReBACTuple {
|
|
6
|
+
objectType: ObjectType;
|
|
7
|
+
objectId: string;
|
|
8
|
+
relation: RelationType;
|
|
9
|
+
subjectType: ObjectType;
|
|
10
|
+
subjectId: string;
|
|
11
|
+
/** For group references, e.g. "member" means subject is "organization:id#member" */
|
|
12
|
+
subjectRelation?: RelationType;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReBACTupleRecord extends ReBACTuple {
|
|
16
|
+
id: number;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReBACUser {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
email: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
/** Present only when freshly generated — never returned by listUsers() */
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CheckResult {
|
|
30
|
+
allowed: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Minimal D1 binding interface (compatible with Cloudflare Workers D1) */
|
|
34
|
+
export interface D1DatabaseBinding {
|
|
35
|
+
prepare(query: string): {
|
|
36
|
+
bind(...values: unknown[]): {
|
|
37
|
+
all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;
|
|
38
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
39
|
+
run(): Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
exec(query: string): Promise<void>;
|
|
43
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|