@tpsdev-ai/flair 0.4.15 → 0.5.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/README.md +49 -0
- package/dist/cli.js +971 -102
- package/dist/keystore.js +116 -0
- package/dist/resources/AdminConnectors.js +54 -0
- package/dist/resources/AdminDashboard.js +68 -0
- package/dist/resources/AdminIdp.js +57 -0
- package/dist/resources/AdminInstance.js +64 -0
- package/dist/resources/AdminMemory.js +92 -0
- package/dist/resources/AdminPrincipals.js +71 -0
- package/dist/resources/Agent.js +49 -2
- package/dist/resources/Credential.js +105 -0
- package/dist/resources/Federation.js +315 -0
- package/dist/resources/Memory.js +11 -0
- package/dist/resources/MemoryBootstrap.js +91 -4
- package/dist/resources/OAuth.js +381 -0
- package/dist/resources/Relationship.js +96 -0
- package/dist/resources/SemanticSearch.js +20 -4
- package/dist/resources/XAA.js +264 -0
- package/dist/resources/admin-layout.js +94 -0
- package/dist/resources/auth-middleware.js +39 -21
- package/dist/resources/embeddings-provider.js +33 -30
- package/dist/resources/federation-crypto.js +52 -0
- package/dist/resources/health.js +67 -1
- package/dist/src/keystore.js +116 -0
- package/package.json +5 -3
- package/schemas/agent.graphql +51 -2
- package/schemas/federation.graphql +51 -0
- package/schemas/memory.graphql +20 -0
- package/schemas/oauth.graphql +72 -0
package/dist/resources/health.js
CHANGED
|
@@ -1,6 +1,72 @@
|
|
|
1
|
-
import { Resource } from "@harperfast/harper";
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
const db = databases;
|
|
3
|
+
/**
|
|
4
|
+
* Health endpoint — unauthenticated, returns only { ok: true }.
|
|
5
|
+
*
|
|
6
|
+
* Rich stats (memory counts, agent names, etc.) are behind /HealthDetail
|
|
7
|
+
* which requires authentication. This prevents information leakage on
|
|
8
|
+
* publicly exposed instances.
|
|
9
|
+
*/
|
|
2
10
|
export class Health extends Resource {
|
|
3
11
|
async get() {
|
|
4
12
|
return { ok: true };
|
|
5
13
|
}
|
|
6
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Authenticated health detail — returns memory/agent/soul stats + process info.
|
|
17
|
+
* Requires Ed25519 agent auth or admin basic auth.
|
|
18
|
+
*/
|
|
19
|
+
export class HealthDetail extends Resource {
|
|
20
|
+
async get() {
|
|
21
|
+
const stats = { ok: true };
|
|
22
|
+
// ── Memory stats ──
|
|
23
|
+
try {
|
|
24
|
+
const list = [];
|
|
25
|
+
for await (const m of db.flair.Memory.search({})) {
|
|
26
|
+
list.push(m);
|
|
27
|
+
}
|
|
28
|
+
stats.memories = {
|
|
29
|
+
total: list.length,
|
|
30
|
+
withEmbeddings: list.filter((m) => m.embeddingModel && m.embeddingModel !== "hash-512d").length,
|
|
31
|
+
hashFallback: list.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d").length,
|
|
32
|
+
};
|
|
33
|
+
if (list.length > 0) {
|
|
34
|
+
const sorted = list.filter((m) => m.createdAt).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
35
|
+
if (sorted[0])
|
|
36
|
+
stats.lastWrite = sorted[0].createdAt;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
stats.memories = null;
|
|
41
|
+
}
|
|
42
|
+
// ── Agent stats ──
|
|
43
|
+
try {
|
|
44
|
+
const agents = [];
|
|
45
|
+
for await (const a of db.flair.Agent.search({})) {
|
|
46
|
+
agents.push(a);
|
|
47
|
+
}
|
|
48
|
+
stats.agents = {
|
|
49
|
+
count: agents.length,
|
|
50
|
+
names: agents.map((a) => a.id).filter(Boolean),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
stats.agents = null;
|
|
55
|
+
}
|
|
56
|
+
// ── Soul stats ──
|
|
57
|
+
try {
|
|
58
|
+
let count = 0;
|
|
59
|
+
for await (const _ of db.flair.Soul.search({})) {
|
|
60
|
+
count++;
|
|
61
|
+
}
|
|
62
|
+
stats.soulEntries = count;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
stats.soulEntries = null;
|
|
66
|
+
}
|
|
67
|
+
// ── Process info ──
|
|
68
|
+
stats.pid = process.pid;
|
|
69
|
+
stats.uptimeSeconds = Math.floor(process.uptime());
|
|
70
|
+
return stats;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keystore — encrypted file-based storage for Ed25519 private key seeds.
|
|
3
|
+
*
|
|
4
|
+
* Primary: AES-256-GCM encrypted files at ~/.flair/keys/<instanceId>.key
|
|
5
|
+
* Fallback: HarperDB (migration path from pre-keystore installs)
|
|
6
|
+
*
|
|
7
|
+
* Encryption key derived via HKDF from FLAIR_KEY_PASSPHRASE env var,
|
|
8
|
+
* or an auto-generated random passphrase stored at ~/.flair/keys/.passphrase
|
|
9
|
+
* (mode 0600). Never falls back to guessable data.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { randomBytes, createCipheriv, createDecipheriv, hkdfSync, } from "node:crypto";
|
|
15
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
16
|
+
function keysDir() {
|
|
17
|
+
return join(homedir(), ".flair", "keys");
|
|
18
|
+
}
|
|
19
|
+
function keyPath(instanceId) {
|
|
20
|
+
// Sanitize instanceId to prevent directory traversal
|
|
21
|
+
const safe = instanceId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
22
|
+
return join(keysDir(), `${safe}.key`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Path to the auto-generated passphrase file.
|
|
26
|
+
* Created on first keystore use if FLAIR_KEY_PASSPHRASE env var is not set.
|
|
27
|
+
*/
|
|
28
|
+
function passphrasePath() {
|
|
29
|
+
return join(keysDir(), ".passphrase");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Get or create the keystore passphrase.
|
|
33
|
+
* Priority: FLAIR_KEY_PASSPHRASE env var > auto-generated file.
|
|
34
|
+
* Never falls back to guessable data (hostname, username, etc.).
|
|
35
|
+
*/
|
|
36
|
+
function getPassphrase() {
|
|
37
|
+
// Explicit env var takes priority
|
|
38
|
+
if (process.env.FLAIR_KEY_PASSPHRASE) {
|
|
39
|
+
return process.env.FLAIR_KEY_PASSPHRASE;
|
|
40
|
+
}
|
|
41
|
+
const pp = passphrasePath();
|
|
42
|
+
// Read existing auto-generated passphrase
|
|
43
|
+
if (existsSync(pp)) {
|
|
44
|
+
return readFileSync(pp, "utf-8").trim();
|
|
45
|
+
}
|
|
46
|
+
// Generate a cryptographically random passphrase and persist it
|
|
47
|
+
const dir = keysDir();
|
|
48
|
+
if (!existsSync(dir)) {
|
|
49
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
}
|
|
51
|
+
const generated = randomBytes(32).toString("base64url");
|
|
52
|
+
writeFileSync(pp, generated, { mode: 0o600 });
|
|
53
|
+
return generated;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Derive a 256-bit encryption key using HKDF.
|
|
57
|
+
* Input keying material: FLAIR_KEY_PASSPHRASE env var, or auto-generated random passphrase.
|
|
58
|
+
*/
|
|
59
|
+
function deriveKey() {
|
|
60
|
+
const passphrase = getPassphrase();
|
|
61
|
+
return Buffer.from(hkdfSync("sha256", passphrase, "flair-keystore-salt", "flair-key-encryption", 32));
|
|
62
|
+
}
|
|
63
|
+
// ─── File-based encrypted keystore ──────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* Encrypt a seed with AES-256-GCM.
|
|
66
|
+
* File format: 12-byte IV | 16-byte auth tag | ciphertext
|
|
67
|
+
*/
|
|
68
|
+
function encryptSeed(seed) {
|
|
69
|
+
const key = deriveKey();
|
|
70
|
+
const iv = randomBytes(12);
|
|
71
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
72
|
+
const encrypted = Buffer.concat([cipher.update(seed), cipher.final()]);
|
|
73
|
+
const tag = cipher.getAuthTag();
|
|
74
|
+
return Buffer.concat([iv, tag, encrypted]);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Decrypt a seed from the file format.
|
|
78
|
+
*/
|
|
79
|
+
function decryptSeed(data) {
|
|
80
|
+
const key = deriveKey();
|
|
81
|
+
const iv = data.subarray(0, 12);
|
|
82
|
+
const tag = data.subarray(12, 28);
|
|
83
|
+
const ciphertext = data.subarray(28);
|
|
84
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
|
85
|
+
decipher.setAuthTag(tag);
|
|
86
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
87
|
+
return new Uint8Array(decrypted);
|
|
88
|
+
}
|
|
89
|
+
// ─── KeyStore implementation ────────────────────────────────────────────────
|
|
90
|
+
class FileKeyStore {
|
|
91
|
+
getPrivateKeySeed(instanceId) {
|
|
92
|
+
const p = keyPath(instanceId);
|
|
93
|
+
if (!existsSync(p))
|
|
94
|
+
return null;
|
|
95
|
+
try {
|
|
96
|
+
const data = readFileSync(p);
|
|
97
|
+
return decryptSeed(data);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
setPrivateKeySeed(instanceId, seed) {
|
|
104
|
+
const dir = keysDir();
|
|
105
|
+
if (!existsSync(dir)) {
|
|
106
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
}
|
|
108
|
+
const p = keyPath(instanceId);
|
|
109
|
+
const encrypted = encryptSeed(seed);
|
|
110
|
+
writeFileSync(p, encrypted, { mode: 0o600 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Singleton keystore instance. */
|
|
114
|
+
export const keystore = new FileKeyStore();
|
|
115
|
+
// Export helpers for testing
|
|
116
|
+
export { encryptSeed, decryptSeed, deriveKey, keyPath, keysDir };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"build": "tsc -p tsconfig.json --noCheck",
|
|
40
40
|
"build:cli": "tsc -p tsconfig.cli.json --noCheck",
|
|
41
41
|
"prepublishOnly": "npm run build && npm run build:cli",
|
|
42
|
-
"test": "bun test"
|
|
42
|
+
"test": "bun test",
|
|
43
|
+
"release": "./scripts/release.sh"
|
|
43
44
|
},
|
|
44
45
|
"publishConfig": {
|
|
45
46
|
"access": "public"
|
|
@@ -48,9 +49,10 @@
|
|
|
48
49
|
"node": ">=22"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
|
-
"@harperfast/harper": "5.0.0
|
|
52
|
+
"@harperfast/harper": "5.0.0",
|
|
52
53
|
"commander": "14.0.3",
|
|
53
54
|
"harper-fabric-embeddings": "0.2.2",
|
|
55
|
+
"jose": "^6.2.2",
|
|
54
56
|
"tweetnacl": "1.0.3"
|
|
55
57
|
},
|
|
56
58
|
"devDependencies": {
|
package/schemas/agent.graphql
CHANGED
|
@@ -1,13 +1,62 @@
|
|
|
1
|
+
# Agent table — backward compatible with 0.x, extended for 1.0 Principals.
|
|
2
|
+
# The Agent table IS the Principal table. We keep the name "Agent" for backward
|
|
3
|
+
# compatibility with existing code, queries, and exports. The `kind` field
|
|
4
|
+
# distinguishes humans from agents. Pre-1.0 records without `kind` are treated
|
|
5
|
+
# as agents.
|
|
1
6
|
type Agent @table(database: "flair") @export {
|
|
2
7
|
id: ID @primaryKey
|
|
3
8
|
name: String!
|
|
4
9
|
role: String
|
|
5
|
-
type: String @indexed
|
|
6
|
-
|
|
10
|
+
type: String @indexed # legacy (0.x): "agent", "tool", etc.
|
|
11
|
+
|
|
12
|
+
# 1.0 Principal fields
|
|
13
|
+
kind: String @indexed # "human" | "agent" (default: "agent" for 0.x compat)
|
|
14
|
+
displayName: String # human-friendly name
|
|
15
|
+
status: String @indexed # "active" | "deactivated" (default: "active")
|
|
16
|
+
publicKey: String! # Ed25519 public key
|
|
17
|
+
defaultTrustTier: String @indexed # "endorsed" | "corroborated" | "unverified"
|
|
18
|
+
admin: Boolean @indexed # admin principals can manage other principals
|
|
19
|
+
runtime: String # "openclaw" | "claude-code" | "headless" | "external" | null
|
|
20
|
+
runtimeEndpoint: String # how to reach this principal programmatically
|
|
21
|
+
subjects: [String] # soul-level subject interests
|
|
22
|
+
|
|
23
|
+
createdAt: String!
|
|
24
|
+
updatedAt: String
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Credential table — auth surfaces for Principals.
|
|
28
|
+
# A Principal can have multiple credentials (passkey + bearer token + Ed25519).
|
|
29
|
+
# Credentials are NOT the Ed25519 signing keypair — they're authentication methods.
|
|
30
|
+
type Credential @table(database: "flair") @export {
|
|
31
|
+
id: ID @primaryKey
|
|
32
|
+
principalId: String! @indexed # FK to Agent.id
|
|
33
|
+
kind: String! @indexed # "webauthn" | "bearer-token" | "ed25519" | "idp"
|
|
34
|
+
label: String # "iPhone 16 Pro", "OpenClaw plugin", "Harper Corporate"
|
|
35
|
+
status: String @indexed # "active" | "revoked"
|
|
36
|
+
|
|
37
|
+
# Bearer token (kind: "bearer-token")
|
|
38
|
+
tokenHash: String # bcrypt/sha256 hash — never store raw token
|
|
39
|
+
tokenPrefix: String # first 8 chars for identification ("flair_at_")
|
|
40
|
+
|
|
41
|
+
# WebAuthn (kind: "webauthn")
|
|
42
|
+
webauthnCredentialId: String # base64url credential ID
|
|
43
|
+
webauthnPublicKey: String # COSE public key
|
|
44
|
+
webauthnAaguid: String # authenticator identifier
|
|
45
|
+
webauthnTransports: [String] # ["internal", "hybrid", "usb"]
|
|
46
|
+
webauthnCounter: Int # signature counter for replay detection
|
|
47
|
+
|
|
48
|
+
# IdP (kind: "idp") — for XAA enterprise auth
|
|
49
|
+
idpProvider: String # IdP config ID
|
|
50
|
+
idpSubject: String @indexed # stable user identifier from IdP
|
|
51
|
+
idpEmail: String # email from IdP claims
|
|
52
|
+
|
|
7
53
|
createdAt: String!
|
|
54
|
+
lastUsedAt: String
|
|
8
55
|
updatedAt: String
|
|
9
56
|
}
|
|
10
57
|
|
|
58
|
+
# Integration table — kept for backward compat with 0.x platform connections.
|
|
59
|
+
# New code should use Credential instead.
|
|
11
60
|
type Integration @table(database: "flair") @export {
|
|
12
61
|
id: ID @primaryKey
|
|
13
62
|
agentId: String! @indexed
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Federation tables — hub-and-spoke sync per FLAIR-FEDERATION spec.
|
|
2
|
+
|
|
3
|
+
# Instance identity — one record per Flair instance.
|
|
4
|
+
type Instance @table(database: "flair") @export {
|
|
5
|
+
id: ID @primaryKey # "flair_h82kx9" — generated on first boot
|
|
6
|
+
publicKey: String! # Ed25519 instance key (base64url)
|
|
7
|
+
role: String @indexed # "hub" | "spoke"
|
|
8
|
+
fabricEndpoint: String # wss:// URL if hub
|
|
9
|
+
status: String @indexed # "active" | "paused" | "decommissioned"
|
|
10
|
+
lastSeen: String
|
|
11
|
+
createdAt: String! @indexed
|
|
12
|
+
updatedAt: String
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# One-time pairing tokens — generated by hub admin, consumed during peer pairing.
|
|
16
|
+
type PairingToken @table(database: "flair") @export {
|
|
17
|
+
id: ID @primaryKey # the token value (crypto random)
|
|
18
|
+
createdBy: String # admin who generated it
|
|
19
|
+
consumedBy: String # peer instanceId that used it
|
|
20
|
+
consumedAt: String # when it was consumed
|
|
21
|
+
expiresAt: String! @indexed # token TTL (default 1 hour)
|
|
22
|
+
createdAt: String! @indexed
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Peer records — known federation peers.
|
|
26
|
+
# Hub has one entry per spoke. Spokes have one entry for the hub.
|
|
27
|
+
type Peer @table(database: "flair") @export {
|
|
28
|
+
id: ID @primaryKey # peer's instance ID
|
|
29
|
+
publicKey: String! # peer's Ed25519 public key (pinned on first pair)
|
|
30
|
+
role: String @indexed # "hub" | "spoke"
|
|
31
|
+
endpoint: String # wss:// URL for the peer
|
|
32
|
+
status: String @indexed # "paired" | "connected" | "disconnected" | "revoked"
|
|
33
|
+
lastSyncAt: String # last successful sync timestamp
|
|
34
|
+
lastSyncCursor: String # Lamport clock or ISO timestamp for incremental sync
|
|
35
|
+
relayOnly: Boolean # true for spoke-to-spoke peers announced by hub
|
|
36
|
+
pairedAt: String
|
|
37
|
+
createdAt: String! @indexed
|
|
38
|
+
updatedAt: String
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Sync log — audit trail for federation operations.
|
|
42
|
+
type SyncLog @table(database: "flair") {
|
|
43
|
+
id: ID @primaryKey
|
|
44
|
+
peerId: String! @indexed # which peer this sync was with
|
|
45
|
+
direction: String! @indexed # "push" | "pull"
|
|
46
|
+
recordCount: Int # how many records in this sync batch
|
|
47
|
+
status: String @indexed # "success" | "partial" | "failed"
|
|
48
|
+
error: String # error message if failed
|
|
49
|
+
durationMs: Int # how long the sync took
|
|
50
|
+
createdAt: String! @indexed
|
|
51
|
+
}
|
package/schemas/memory.graphql
CHANGED
|
@@ -26,6 +26,26 @@ type Memory @table(database: "flair") {
|
|
|
26
26
|
lastReflected: String # ISO timestamp of last reflection pass
|
|
27
27
|
supersedes: String @indexed # ID of memory this one replaces (version chain)
|
|
28
28
|
subject: String @indexed # entity this memory is about (person, service, project)
|
|
29
|
+
validFrom: String @indexed # ISO timestamp — when this fact became true
|
|
30
|
+
validTo: String @indexed # ISO timestamp — when this fact stopped being true (null = still valid)
|
|
31
|
+
_safetyFlags: [String] # content safety flags from scanContent()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Explicit entity-to-entity relationships with temporal validity.
|
|
35
|
+
# Enables queries like "who was team lead in Q1?" or "what changed about X on date Y?"
|
|
36
|
+
# Inspired by knowledge graph triples with time-bounded validity windows.
|
|
37
|
+
type Relationship @table(database: "flair") {
|
|
38
|
+
id: ID @primaryKey
|
|
39
|
+
agentId: String! @indexed # who created this relationship
|
|
40
|
+
subject: String! @indexed # source entity (person, project, service)
|
|
41
|
+
predicate: String! @indexed # relationship type (manages, owns, depends_on, etc.)
|
|
42
|
+
object: String! @indexed # target entity
|
|
43
|
+
validFrom: String @indexed # when this relationship became true
|
|
44
|
+
validTo: String @indexed # when it ended (null = still active)
|
|
45
|
+
confidence: Float # 0.0–1.0, how certain (1.0 = explicitly stated)
|
|
46
|
+
source: String # where this was learned (memory ID, conversation, etc.)
|
|
47
|
+
createdAt: String! @indexed
|
|
48
|
+
updatedAt: String
|
|
29
49
|
}
|
|
30
50
|
|
|
31
51
|
type Soul @table(database: "flair") {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# OAuth 2.1 Authorization Server tables.
|
|
2
|
+
# Implements: RFC 6749 (OAuth 2.0), RFC 7591 (DCR), RFC 7636 (PKCE),
|
|
3
|
+
# with 1.0 constraints from FLAIR-PRINCIPALS § 2.
|
|
4
|
+
|
|
5
|
+
# Registered OAuth clients (via DCR or admin).
|
|
6
|
+
# 1.0: only claude.com redirect URI permitted for DCR clients.
|
|
7
|
+
type OAuthClient @table(database: "flair") @export {
|
|
8
|
+
id: ID @primaryKey # client_id
|
|
9
|
+
clientSecret: String # hashed — only for confidential clients
|
|
10
|
+
name: String!
|
|
11
|
+
redirectUris: [String]! # 1.0: only ["https://claude.com/api/mcp/auth_callback"]
|
|
12
|
+
grantTypes: [String] # ["authorization_code", "refresh_token"]
|
|
13
|
+
scope: String # space-separated default scopes
|
|
14
|
+
registeredBy: String @indexed # principal ID who registered, or "dcr"
|
|
15
|
+
createdAt: String! @indexed
|
|
16
|
+
updatedAt: String
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Authorization codes — short-lived, single-use.
|
|
20
|
+
type OAuthAuthCode @table(database: "flair") {
|
|
21
|
+
id: ID @primaryKey # the authorization code itself (random, URL-safe)
|
|
22
|
+
clientId: String! @indexed
|
|
23
|
+
principalId: String! @indexed # who authorized
|
|
24
|
+
redirectUri: String!
|
|
25
|
+
scope: String # granted scopes
|
|
26
|
+
codeChallenge: String # PKCE S256
|
|
27
|
+
codeChallengeMethod: String # "S256"
|
|
28
|
+
expiresAt: String! @indexed # short TTL (10 min max)
|
|
29
|
+
used: Boolean @indexed # single-use enforcement
|
|
30
|
+
createdAt: String!
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# IdP configuration for XAA (Enterprise-Managed Authorization).
|
|
34
|
+
# Stores trusted enterprise IdP endpoints for ID-JAG validation.
|
|
35
|
+
type IdpConfig @table(database: "flair") @export {
|
|
36
|
+
id: ID @primaryKey # stable UUID
|
|
37
|
+
name: String! # display name ("Harper Corporate")
|
|
38
|
+
issuer: String! @indexed # must match ID-JAG `iss` (e.g., "https://accounts.google.com")
|
|
39
|
+
jwksUri: String! # JWKS endpoint for signature verification
|
|
40
|
+
clientId: String! # Flair's client_id at this IdP
|
|
41
|
+
requiredDomain: String # domain boundary: `hd` (Google), `tid` (Azure), issuer-scoped (Okta)
|
|
42
|
+
jitProvision: Boolean # auto-create principals on first login
|
|
43
|
+
defaultTrustTier: String # "unverified" | "corroborated" for JIT principals
|
|
44
|
+
allowedScopes: [String] # restrict which scopes this IdP can grant
|
|
45
|
+
enabled: Boolean @indexed
|
|
46
|
+
jwksCachedAt: String # when JWKS was last fetched
|
|
47
|
+
createdAt: String! @indexed
|
|
48
|
+
updatedAt: String
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# ID-JAG replay prevention — tracks used `jti` claims.
|
|
52
|
+
type IdJagReplay @table(database: "flair") {
|
|
53
|
+
id: ID @primaryKey # the jti value
|
|
54
|
+
expiresAt: String! @indexed # TTL for cleanup
|
|
55
|
+
createdAt: String!
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Access + refresh tokens.
|
|
59
|
+
type OAuthToken @table(database: "flair") {
|
|
60
|
+
id: ID @primaryKey # token ID (internal reference)
|
|
61
|
+
tokenHash: String! @indexed # SHA-256 of actual token — never store raw
|
|
62
|
+
tokenType: String! @indexed # "access" | "refresh"
|
|
63
|
+
clientId: String! @indexed
|
|
64
|
+
principalId: String! @indexed
|
|
65
|
+
scope: String # space-separated granted scopes
|
|
66
|
+
expiresAt: String! @indexed
|
|
67
|
+
revokedAt: String # null = active
|
|
68
|
+
parentTokenId: String # refresh token that generated this access token
|
|
69
|
+
idpIssuer: String # if token was issued via XAA, the IdP issuer
|
|
70
|
+
idpSubject: String # if token was issued via XAA, the IdP subject
|
|
71
|
+
createdAt: String! @indexed
|
|
72
|
+
}
|