@happyvertical/smrt-profiles 0.30.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/AGENTS.md +53 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +176 -0
- package/dist/chunks/ApiKey-B2LKEaP8.js +143 -0
- package/dist/chunks/ApiKey-B2LKEaP8.js.map +1 -0
- package/dist/chunks/ApiKeyCollection-B6Op817e.js +91 -0
- package/dist/chunks/ApiKeyCollection-B6Op817e.js.map +1 -0
- package/dist/chunks/AuditLogCollection-BYqCj0uE.js +195 -0
- package/dist/chunks/AuditLogCollection-BYqCj0uE.js.map +1 -0
- package/dist/chunks/NostrIdentityCollection-DadQBHWy.js +3065 -0
- package/dist/chunks/NostrIdentityCollection-DadQBHWy.js.map +1 -0
- package/dist/chunks/ProfileAssetCollection-D_tk1kKG.js +122 -0
- package/dist/chunks/ProfileAssetCollection-D_tk1kKG.js.map +1 -0
- package/dist/chunks/ProfileCollection-DU6wUJTO.js +782 -0
- package/dist/chunks/ProfileCollection-DU6wUJTO.js.map +1 -0
- package/dist/chunks/ProfileMetadataCollection-DEhmljMY.js +120 -0
- package/dist/chunks/ProfileMetadataCollection-DEhmljMY.js.map +1 -0
- package/dist/chunks/ProfileMetafieldCollection-DMKhSHXX.js +184 -0
- package/dist/chunks/ProfileMetafieldCollection-DMKhSHXX.js.map +1 -0
- package/dist/chunks/ProfileRelationshipCollection-C0IM8UQR.js +177 -0
- package/dist/chunks/ProfileRelationshipCollection-C0IM8UQR.js.map +1 -0
- package/dist/chunks/ProfileRelationshipTermCollection-CXem_qT-.js +117 -0
- package/dist/chunks/ProfileRelationshipTermCollection-CXem_qT-.js.map +1 -0
- package/dist/chunks/ProfileRelationshipType-BXBLldea.js +103 -0
- package/dist/chunks/ProfileRelationshipType-BXBLldea.js.map +1 -0
- package/dist/chunks/ProfileRelationshipTypeCollection-CF8YvLTV.js +48 -0
- package/dist/chunks/ProfileRelationshipTypeCollection-CF8YvLTV.js.map +1 -0
- package/dist/chunks/index-jFtOWsAV.js +1014 -0
- package/dist/chunks/index-jFtOWsAV.js.map +1 -0
- package/dist/index.d.ts +1848 -0
- package/dist/index.js +70 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +11829 -0
- package/dist/smrt-knowledge.json +3846 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +61 -0
- package/dist/utils.js +49 -0
- package/dist/utils.js.map +1 -0
- package/package.json +75 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @happyvertical/smrt-profiles
|
|
2
|
+
|
|
3
|
+
Central identity system with multi-auth, relationships, controlled metadata, and audit logging.
|
|
4
|
+
|
|
5
|
+
## Models
|
|
6
|
+
|
|
7
|
+
- **Profile** (STI base → Bot, Organization, Person): email (globally unique), `typeId` FK to ProfileType, plus a `metadata` `@oneToMany('ProfileMetadata')` relationship for controlled per-profile values.
|
|
8
|
+
- **ProfileAsset**: dedicated owned-asset join in `profile_assets` with `relationship` and `sortOrder`.
|
|
9
|
+
- **ProfileRelationship**: bidirectional — creating one auto-creates reciprocal inverse. `contextProfileId` for tertiary relationships. `ProfileRelationshipTerm` tracks start/end dates.
|
|
10
|
+
- **ProfileMetafield**: controlled vocabulary with `validationSchema`. **ProfileMetadata**: per-profile values linked to metafields.
|
|
11
|
+
- **AuditLog**: action, resourceType/Id, `source` (web/cli/ci/webhook/mcp), `onBehalfOfId` for CI pass-through identity. `allowSuperAdminBypass: true`.
|
|
12
|
+
|
|
13
|
+
## Auth Methods
|
|
14
|
+
|
|
15
|
+
| Model | Pattern |
|
|
16
|
+
|-------|---------|
|
|
17
|
+
| NostrIdentity | Encrypted keypair (AES-256-GCM). Requires `SERVER_MASTER_SECRET` env var for decryption. NIP-05 address generation. |
|
|
18
|
+
| OidcIdentity | Multiple issuers (Keycloak/Google/GitHub). Lookup by `issuer + subject` pair. `findOrCreate()` for first login. |
|
|
19
|
+
| ApiKey | SHA-256 hashed. **Plaintext returned once only** on `generate()`. `keyPrefix` for identification. Scope-based with expiry. |
|
|
20
|
+
| MagicLinkToken | One-time token with expiry for passwordless auth. |
|
|
21
|
+
|
|
22
|
+
## Identity Resolution
|
|
23
|
+
|
|
24
|
+
Auth helpers in `src/auth/` build profiles from external identity claims:
|
|
25
|
+
|
|
26
|
+
- `resolveIdentity()` — top-level dispatcher that returns/creates a Profile from Nostr signatures, OIDC claims, magic link tokens, or API keys.
|
|
27
|
+
- `createProfileFromOidc(claims, provider)` — creates `Profile` + `OidcIdentity` for first-time OIDC sign-in.
|
|
28
|
+
- `createProfileFromNostr(email, nostrData)` — creates `Profile` + `NostrIdentity` for Nostr-authenticated users.
|
|
29
|
+
|
|
30
|
+
## Key Methods
|
|
31
|
+
|
|
32
|
+
- `Profile.getAssets()` / `addAsset()` / `removeAsset()` and the matching `ProfileCollection` wrappers — canonical owned asset helpers backed by `profile_assets`.
|
|
33
|
+
- `Profile.addMetadata(metafieldSlug, value)` / `Profile.getMetadata()` — validates against metafield schema. `ProfileCollection.batchGetMetadata()` / `batchUpdateMetadata()` for bulk reads/writes.
|
|
34
|
+
- `Profile.getRelationships({ direction: 'from'|'to'|'all' })` — direction matters.
|
|
35
|
+
- `Profile.getRelationshipsFrom()` / `getRelationshipsTo()` — R10-generated `@oneToMany` accessors. ProfileRelationship has two FKs back to Profile, so each `@oneToMany` annotates its inverse explicitly (`{ foreignKey: 'fromProfileId' }` / `'toProfileId'`). Return raw `ProfileRelationship[]`; use `getRelationships()` for slug/direction filtering.
|
|
36
|
+
- AI: `generateBio()` (uses `smrtProfiles.profile.generateBio` prompt via `@happyvertical/smrt-prompts`), `matches(criteria)` (delegates to `is()`).
|
|
37
|
+
|
|
38
|
+
## Prompt Registry
|
|
39
|
+
|
|
40
|
+
`generateBio()` is registered with `@happyvertical/smrt-prompts` so tenants can override template/model/params at runtime:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { smrtProfilesGenerateBioPrompt } from '@happyvertical/smrt-profiles';
|
|
44
|
+
// key: 'smrtProfiles.profile.generateBio'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Gotchas
|
|
48
|
+
|
|
49
|
+
- **SERVER_MASTER_SECRET required** for Nostr private key decryption — centralized key management
|
|
50
|
+
- **API key never returned again**: `ApiKey.generate()` returns plaintext once; only `keyPrefix` visible later
|
|
51
|
+
- **OIDC unique per issuer+subject**: same subject from different issuers = different identities
|
|
52
|
+
- **Email unique across all profiles**: DB-level unique constraint, not per-tenant
|
|
53
|
+
- **Optional tenancy** on Profile; AuditLog allows super-admin bypass
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <Happy Vertical Corporation>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# @happyvertical/smrt-profiles
|
|
2
|
+
|
|
3
|
+
Central identity system with multi-auth (Nostr/OIDC/API keys/magic links), relationships, controlled metadata, and audit logging.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @happyvertical/smrt-profiles
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import {
|
|
15
|
+
Profile,
|
|
16
|
+
ProfileCollection,
|
|
17
|
+
OidcIdentity,
|
|
18
|
+
OidcIdentityCollection,
|
|
19
|
+
ApiKey,
|
|
20
|
+
ApiKeyCollection,
|
|
21
|
+
NostrIdentity,
|
|
22
|
+
generateNostrKeypair,
|
|
23
|
+
createProfileFromOidc,
|
|
24
|
+
} from '@happyvertical/smrt-profiles';
|
|
25
|
+
|
|
26
|
+
// Create a profile
|
|
27
|
+
const profile = new Profile({
|
|
28
|
+
name: 'Alice Johnson',
|
|
29
|
+
email: 'alice@example.com',
|
|
30
|
+
});
|
|
31
|
+
await profile.save();
|
|
32
|
+
|
|
33
|
+
// OIDC identity (Keycloak/Google/GitHub)
|
|
34
|
+
const oidcProfile = await createProfileFromOidc({
|
|
35
|
+
issuer: 'https://accounts.google.com',
|
|
36
|
+
subject: 'abc123',
|
|
37
|
+
email: 'alice@example.com',
|
|
38
|
+
name: 'Alice Johnson',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Nostr identity (encrypted keypair, requires SERVER_MASTER_SECRET)
|
|
42
|
+
const keypair = generateNostrKeypair();
|
|
43
|
+
const nostr = new NostrIdentity({
|
|
44
|
+
profileId: profile.id,
|
|
45
|
+
pubkey: keypair.pubkey,
|
|
46
|
+
});
|
|
47
|
+
await nostr.save();
|
|
48
|
+
|
|
49
|
+
// API key (plaintext returned once only)
|
|
50
|
+
const { key, apiKey } = await ApiKey.generate({
|
|
51
|
+
profileId: profile.id,
|
|
52
|
+
scope: 'read:profiles',
|
|
53
|
+
expiresAt: new Date('2025-12-31'),
|
|
54
|
+
});
|
|
55
|
+
// key = plaintext (store now), apiKey.keyPrefix = visible identifier
|
|
56
|
+
|
|
57
|
+
// Relationships (auto-creates reciprocal inverse)
|
|
58
|
+
const bob = new Profile({ name: 'Bob Smith', email: 'bob@example.com' });
|
|
59
|
+
await bob.save();
|
|
60
|
+
await profile.addRelationship(bob, 'friend');
|
|
61
|
+
const friends = await profile.getRelationships({ direction: 'from' });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Owned assets
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { AssetCollection } from '@happyvertical/smrt-assets';
|
|
68
|
+
|
|
69
|
+
const profiles = await ProfileCollection.create();
|
|
70
|
+
const assets = await AssetCollection.create();
|
|
71
|
+
|
|
72
|
+
const headshot = await assets.create({
|
|
73
|
+
name: 'alice-headshot.jpg',
|
|
74
|
+
sourceUri: 'file:///tmp/alice-headshot.jpg',
|
|
75
|
+
mimeType: 'image/jpeg',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await profile.addAsset(headshot, 'avatar');
|
|
79
|
+
await profiles.addAsset(profile.id!, headshot, 'gallery', 1);
|
|
80
|
+
|
|
81
|
+
const avatarAssets = await profile.getAssets('avatar');
|
|
82
|
+
const galleryAssets = await profiles.getAssets(profile.id!, 'gallery');
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## API
|
|
86
|
+
|
|
87
|
+
### Models
|
|
88
|
+
|
|
89
|
+
| Export | Description |
|
|
90
|
+
|--------|------------|
|
|
91
|
+
| `Profile` | Core identity (STI base for Bot/Organization/Person) |
|
|
92
|
+
| `Bot` | STI subclass for automated agents |
|
|
93
|
+
| `Organization` | STI subclass for companies/groups |
|
|
94
|
+
| `Person` | STI subclass for individuals |
|
|
95
|
+
| `ProfileType` | Profile classification lookup table |
|
|
96
|
+
| `ProfileMetadata` | Per-profile metadata values |
|
|
97
|
+
| `ProfileMetafield` | Controlled vocabulary with validation schema |
|
|
98
|
+
| `ProfileRelationship` | Directional link between two profiles |
|
|
99
|
+
| `ProfileRelationshipType` | Relationship classification with reciprocal flag |
|
|
100
|
+
| `ProfileRelationshipTerm` | Time-bounded relationship periods |
|
|
101
|
+
| `ProfileAsset` | Dedicated owned-asset join stored in `profile_assets` with `relationship` and `sortOrder` |
|
|
102
|
+
|
|
103
|
+
### Auth Models
|
|
104
|
+
|
|
105
|
+
| Export | Description |
|
|
106
|
+
|--------|------------|
|
|
107
|
+
| `OidcIdentity` | OIDC provider identity (issuer + subject) |
|
|
108
|
+
| `NostrIdentity` | Nostr keypair with AES-256-GCM encryption |
|
|
109
|
+
| `ApiKey` | SHA-256 hashed API key with scope and expiry |
|
|
110
|
+
| `MagicLinkToken` | One-time passwordless auth token |
|
|
111
|
+
| `AuditLog` | Action/resource audit trail with source tracking |
|
|
112
|
+
|
|
113
|
+
### Collections
|
|
114
|
+
|
|
115
|
+
| Export | Description |
|
|
116
|
+
|--------|------------|
|
|
117
|
+
| `ProfileCollection` | CRUD and query for profiles |
|
|
118
|
+
| `ProfileAssetCollection` | Direct access to `profile_assets` rows plus asset helper wrappers |
|
|
119
|
+
| `ProfileTypeCollection` | Profile type management |
|
|
120
|
+
| `ProfileMetadataCollection` | Metadata value operations |
|
|
121
|
+
| `ProfileMetafieldCollection` | Metafield vocabulary management |
|
|
122
|
+
| `ProfileRelationshipCollection` | Relationship queries |
|
|
123
|
+
| `ProfileRelationshipTypeCollection` | Relationship type management |
|
|
124
|
+
| `ProfileRelationshipTermCollection` | Term period management |
|
|
125
|
+
| `ApiKeyCollection` | API key lookup and management |
|
|
126
|
+
| `AuditLogCollection` | Audit log queries |
|
|
127
|
+
| `MagicLinkTokenCollection` | Magic link token operations |
|
|
128
|
+
| `NostrIdentityCollection` | Nostr identity lookup (includes NIP-05) |
|
|
129
|
+
| `OidcIdentityCollection` | OIDC identity lookup |
|
|
130
|
+
|
|
131
|
+
`Profile` and `ProfileCollection` both expose `getAssets()`, `addAsset()`, and
|
|
132
|
+
`removeAsset()` helpers backed by `profile_assets`. Typical relationships are
|
|
133
|
+
`avatar`, `gallery`, and `attachment`.
|
|
134
|
+
|
|
135
|
+
### Auth Functions
|
|
136
|
+
|
|
137
|
+
| Export | Description |
|
|
138
|
+
|--------|------------|
|
|
139
|
+
| `resolveIdentity` | Resolve profile from any auth method |
|
|
140
|
+
| `createProfileFromOidc` | Create profile + OIDC identity in one call |
|
|
141
|
+
| `createProfileFromNostr` | Create profile + Nostr identity in one call |
|
|
142
|
+
| `createAuthEvent` | Create a Nostr auth event |
|
|
143
|
+
| `verifyAuthEvent` | Verify a Nostr auth event signature |
|
|
144
|
+
| `createMagicLinkService` | Factory for magic link auth service |
|
|
145
|
+
| `createNip05Handler` | Factory for NIP-05 address handler |
|
|
146
|
+
|
|
147
|
+
### Nostr Crypto
|
|
148
|
+
|
|
149
|
+
| Export | Description |
|
|
150
|
+
|--------|------------|
|
|
151
|
+
| `generateNostrKeypair` | Generate new Nostr keypair |
|
|
152
|
+
| `encryptPrivkey` / `decryptPrivkey` | AES-256-GCM key encryption |
|
|
153
|
+
| `deriveEncryptionKey` | Derive encryption key from master secret |
|
|
154
|
+
| `getPublicKey` | Derive pubkey from privkey |
|
|
155
|
+
| `signEvent` / `computeEventId` | Nostr event signing |
|
|
156
|
+
| `verifyNostrSignature` | Verify Nostr signature |
|
|
157
|
+
| `pubkeyToNpub` / `npubToPubkey` | Bech32 pubkey conversion |
|
|
158
|
+
| `privkeyToNsec` / `nsecToPrivkey` | Bech32 privkey conversion |
|
|
159
|
+
| `isValidPubkey` / `isValidPrivkey` | Key validation |
|
|
160
|
+
| `parseNip05Identifier` / `isValidNip05Identifier` | NIP-05 parsing |
|
|
161
|
+
|
|
162
|
+
### Key Types
|
|
163
|
+
|
|
164
|
+
`ProfileOptions`, `ProfileTypeOptions`, `ProfileMetadataOptions`, `ProfileMetafieldOptions`, `ProfileRelationshipOptions`, `ProfileRelationshipTypeOptions`, `ProfileRelationshipTermOptions`, `OidcIdentityOptions`, `NostrIdentityOptions`, `ApiKeyOptions`, `GenerateKeyResult`, `MagicLinkTokenOptions`, `GenerateTokenResult`, `AuditLogOptions`, `AuditSource`, `AuthContext`, `ResolveIdentityResult`, `InitiateResult`, `VerifyResult`, `MagicLinkConfig`, `MagicLinkService`, `Nip05HandlerConfig`, `Nip05HandlerResult`, `Nip05Request`, `Nip05Response`, `NostrEvent`, `NostrKeypair`, `EncryptedKey`, `ValidationSchema`, `ValidatorFunction`, `ReciprocalHandler`
|
|
165
|
+
|
|
166
|
+
## Dependencies
|
|
167
|
+
|
|
168
|
+
- `@happyvertical/smrt-core` -- ORM base classes
|
|
169
|
+
- `@happyvertical/ai` -- AI client (SDK)
|
|
170
|
+
- `@happyvertical/sql` -- Database operations (SDK)
|
|
171
|
+
- `@happyvertical/files` -- Filesystem utilities (SDK)
|
|
172
|
+
- `@happyvertical/logger` -- Structured logging (SDK)
|
|
173
|
+
- `@happyvertical/utils` -- Shared utilities (SDK)
|
|
174
|
+
- `@noble/curves` -- Nostr cryptography
|
|
175
|
+
- `bech32` -- Bech32 encoding for Nostr keys
|
|
176
|
+
- Peer: `@happyvertical/smrt-tenancy`
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { foreignKey, smrt, SmrtObject } from "@happyvertical/smrt-core";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
6
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
7
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
8
|
+
if (decorator = decorators[i])
|
|
9
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
10
|
+
if (kind && result) __defProp(target, key, result);
|
|
11
|
+
return result;
|
|
12
|
+
};
|
|
13
|
+
let ApiKey = class extends SmrtObject {
|
|
14
|
+
profileId;
|
|
15
|
+
/**
|
|
16
|
+
* SHA-256 hash of the API key
|
|
17
|
+
*/
|
|
18
|
+
keyHash = "";
|
|
19
|
+
/**
|
|
20
|
+
* First 8 characters of the key for identification (e.g., "sk_live_a1b2...")
|
|
21
|
+
*/
|
|
22
|
+
keyPrefix = "";
|
|
23
|
+
/**
|
|
24
|
+
* Human-readable name for the key (e.g., "CI Bot", "Local CLI")
|
|
25
|
+
*/
|
|
26
|
+
name = "";
|
|
27
|
+
/**
|
|
28
|
+
* Scopes/permissions for this key
|
|
29
|
+
*/
|
|
30
|
+
scopes = [];
|
|
31
|
+
/**
|
|
32
|
+
* Last time this key was used
|
|
33
|
+
*/
|
|
34
|
+
lastUsedAt = null;
|
|
35
|
+
/**
|
|
36
|
+
* When this key expires (null = never)
|
|
37
|
+
*/
|
|
38
|
+
expiresAt = null;
|
|
39
|
+
/**
|
|
40
|
+
* When this key was revoked (null = active)
|
|
41
|
+
*/
|
|
42
|
+
revokedAt = null;
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
super(options);
|
|
45
|
+
if (options.profileId) this.profileId = options.profileId;
|
|
46
|
+
if (options.name) this.name = options.name;
|
|
47
|
+
if (options.scopes) this.scopes = options.scopes;
|
|
48
|
+
if (options.expiresAt !== void 0) this.expiresAt = options.expiresAt;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get the linked Profile
|
|
52
|
+
*/
|
|
53
|
+
async getProfile() {
|
|
54
|
+
return await this.getRelated("profileId");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check if this key is valid (not expired, not revoked)
|
|
58
|
+
*/
|
|
59
|
+
isValid() {
|
|
60
|
+
if (this.revokedAt) return false;
|
|
61
|
+
if (this.expiresAt && this.expiresAt < /* @__PURE__ */ new Date()) return false;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if this key has a specific scope
|
|
66
|
+
*/
|
|
67
|
+
hasScope(scope) {
|
|
68
|
+
return this.scopes.includes(scope) || this.scopes.includes("*");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Revoke this key
|
|
72
|
+
*/
|
|
73
|
+
async revoke() {
|
|
74
|
+
this.revokedAt = /* @__PURE__ */ new Date();
|
|
75
|
+
await this.save();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Record usage of this key
|
|
79
|
+
*/
|
|
80
|
+
async recordUsage() {
|
|
81
|
+
this.lastUsedAt = /* @__PURE__ */ new Date();
|
|
82
|
+
await this.save();
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Hash an API key
|
|
86
|
+
*/
|
|
87
|
+
static hashKey(key) {
|
|
88
|
+
return createHash("sha256").update(key).digest("hex");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Generate a new API key for a profile
|
|
92
|
+
*/
|
|
93
|
+
static async generate(profile, options) {
|
|
94
|
+
const randomPart = randomBytes(32).toString("hex");
|
|
95
|
+
const key = `sk_live_${randomPart}`;
|
|
96
|
+
const keyPrefix = key.substring(0, 16);
|
|
97
|
+
const keyHash = ApiKey.hashKey(key);
|
|
98
|
+
const apiKey = new ApiKey({
|
|
99
|
+
db: options.db || profile.options?.db,
|
|
100
|
+
profileId: profile.id,
|
|
101
|
+
name: options.name,
|
|
102
|
+
scopes: options.scopes || ["*"],
|
|
103
|
+
expiresAt: options.expiresAt ?? null
|
|
104
|
+
});
|
|
105
|
+
apiKey.keyHash = keyHash;
|
|
106
|
+
apiKey.keyPrefix = keyPrefix;
|
|
107
|
+
await apiKey.initialize();
|
|
108
|
+
await apiKey.save();
|
|
109
|
+
return { key, apiKey };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Verify an API key and return the ApiKey record if valid
|
|
113
|
+
*/
|
|
114
|
+
static async verify(key, options = {}) {
|
|
115
|
+
const keyHash = ApiKey.hashKey(key);
|
|
116
|
+
const { ApiKeyCollection } = await import("./ApiKeyCollection-B6Op817e.js");
|
|
117
|
+
const collection = await ApiKeyCollection.create(options);
|
|
118
|
+
const apiKey = await collection.findOne({
|
|
119
|
+
where: { keyHash }
|
|
120
|
+
});
|
|
121
|
+
if (!apiKey) return null;
|
|
122
|
+
if (!apiKey.isValid()) return null;
|
|
123
|
+
await apiKey.recordUsage();
|
|
124
|
+
return apiKey;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
__decorateClass([
|
|
128
|
+
foreignKey("Profile", { required: true })
|
|
129
|
+
], ApiKey.prototype, "profileId", 2);
|
|
130
|
+
ApiKey = __decorateClass([
|
|
131
|
+
smrt({
|
|
132
|
+
tableName: "api_keys",
|
|
133
|
+
api: { include: ["list", "get"] },
|
|
134
|
+
mcp: { include: ["list", "get"] },
|
|
135
|
+
// create/revoke are admin operations invoked in-process via the CLI; only
|
|
136
|
+
// read-only list/get are exposed over HTTP.
|
|
137
|
+
cli: { include: ["list", "get", "create", "revoke"], skipApiCheck: true }
|
|
138
|
+
})
|
|
139
|
+
], ApiKey);
|
|
140
|
+
export {
|
|
141
|
+
ApiKey
|
|
142
|
+
};
|
|
143
|
+
//# sourceMappingURL=ApiKey-B2LKEaP8.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ApiKey-B2LKEaP8.js","sources":["../../src/models/ApiKey.ts"],"sourcesContent":["/**\n * ApiKey - API keys for CLI and CI authentication\n *\n * Provides secure API key management with hashing, scopes, and expiration.\n * Keys are stored as hashes - the plaintext is only returned on creation.\n */\n\nimport { createHash, randomBytes } from 'node:crypto';\nimport {\n foreignKey,\n SmrtObject,\n type SmrtObjectOptions,\n smrt,\n} from '@happyvertical/smrt-core';\nimport type { Profile } from './Profile';\n\nexport interface ApiKeyOptions extends SmrtObjectOptions {\n profileId?: string;\n name?: string;\n scopes?: string[];\n expiresAt?: Date | null;\n}\n\nexport interface GenerateKeyResult {\n key: string;\n apiKey: ApiKey;\n}\n\n@smrt({\n tableName: 'api_keys',\n api: { include: ['list', 'get'] },\n mcp: { include: ['list', 'get'] },\n // create/revoke are admin operations invoked in-process via the CLI; only\n // read-only list/get are exposed over HTTP.\n cli: { include: ['list', 'get', 'create', 'revoke'], skipApiCheck: true },\n})\nexport class ApiKey extends SmrtObject {\n /**\n * Link to the Profile (Person, Organization, Bot)\n */\n @foreignKey('Profile', { required: true })\n profileId?: string;\n\n /**\n * SHA-256 hash of the API key\n */\n keyHash: string = '';\n\n /**\n * First 8 characters of the key for identification (e.g., \"sk_live_a1b2...\")\n */\n keyPrefix: string = '';\n\n /**\n * Human-readable name for the key (e.g., \"CI Bot\", \"Local CLI\")\n */\n name: string = '';\n\n /**\n * Scopes/permissions for this key\n */\n scopes: string[] = [];\n\n /**\n * Last time this key was used\n */\n lastUsedAt: Date | null = null;\n\n /**\n * When this key expires (null = never)\n */\n expiresAt: Date | null = null;\n\n /**\n * When this key was revoked (null = active)\n */\n revokedAt: Date | null = null;\n\n constructor(options: ApiKeyOptions = {}) {\n super(options);\n if (options.profileId) this.profileId = options.profileId;\n if (options.name) this.name = options.name;\n if (options.scopes) this.scopes = options.scopes;\n if (options.expiresAt !== undefined) this.expiresAt = options.expiresAt;\n }\n\n /**\n * Get the linked Profile\n */\n async getProfile(): Promise<Profile | null> {\n return (await this.getRelated('profileId')) as Profile | null;\n }\n\n /**\n * Check if this key is valid (not expired, not revoked)\n */\n isValid(): boolean {\n if (this.revokedAt) return false;\n if (this.expiresAt && this.expiresAt < new Date()) return false;\n return true;\n }\n\n /**\n * Check if this key has a specific scope\n */\n hasScope(scope: string): boolean {\n return this.scopes.includes(scope) || this.scopes.includes('*');\n }\n\n /**\n * Revoke this key\n */\n async revoke(): Promise<void> {\n this.revokedAt = new Date();\n await this.save();\n }\n\n /**\n * Record usage of this key\n */\n async recordUsage(): Promise<void> {\n this.lastUsedAt = new Date();\n await this.save();\n }\n\n /**\n * Hash an API key\n */\n static hashKey(key: string): string {\n return createHash('sha256').update(key).digest('hex');\n }\n\n /**\n * Generate a new API key for a profile\n */\n static async generate(\n profile: Profile,\n options: {\n name: string;\n scopes?: string[];\n expiresAt?: Date | null;\n db?: SmrtObjectOptions['db'];\n },\n ): Promise<GenerateKeyResult> {\n // Generate a random key: sk_live_<32 random bytes as hex>\n const randomPart = randomBytes(32).toString('hex');\n const key = `sk_live_${randomPart}`;\n const keyPrefix = key.substring(0, 16);\n const keyHash = ApiKey.hashKey(key);\n\n const apiKey = new ApiKey({\n db: options.db || profile.options?.db,\n profileId: profile.id as string,\n name: options.name,\n scopes: options.scopes || ['*'],\n expiresAt: options.expiresAt ?? null,\n });\n apiKey.keyHash = keyHash;\n apiKey.keyPrefix = keyPrefix;\n\n await apiKey.initialize();\n await apiKey.save();\n\n return { key, apiKey };\n }\n\n /**\n * Verify an API key and return the ApiKey record if valid\n */\n static async verify(\n key: string,\n options: SmrtObjectOptions = {},\n ): Promise<ApiKey | null> {\n const keyHash = ApiKey.hashKey(key);\n\n const { ApiKeyCollection } = await import(\n '../collections/ApiKeyCollection'\n );\n const collection = await (ApiKeyCollection as any).create(options);\n\n const apiKey = await collection.findOne({\n where: { keyHash },\n });\n\n if (!apiKey) return null;\n if (!apiKey.isValid()) return null;\n\n // Record usage\n await apiKey.recordUsage();\n\n return apiKey;\n }\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { SmrtCollection } from "@happyvertical/smrt-core";
|
|
2
|
+
import { ApiKey } from "./ApiKey-B2LKEaP8.js";
|
|
3
|
+
class ApiKeyCollection extends SmrtCollection {
|
|
4
|
+
static _itemClass = ApiKey;
|
|
5
|
+
/**
|
|
6
|
+
* Find all keys for a profile
|
|
7
|
+
*/
|
|
8
|
+
async findByProfile(profileId) {
|
|
9
|
+
return await this.list({
|
|
10
|
+
where: { profileId }
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Find active (non-revoked, non-expired) keys for a profile
|
|
15
|
+
*/
|
|
16
|
+
async findActiveByProfile(profileId) {
|
|
17
|
+
const keys = await this.findByProfile(profileId);
|
|
18
|
+
return keys.filter((key) => key.isValid());
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Find key by hash
|
|
22
|
+
*/
|
|
23
|
+
async findByHash(keyHash) {
|
|
24
|
+
return await this.findOne({
|
|
25
|
+
where: { keyHash }
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Verify an API key string and return the record if valid
|
|
30
|
+
*/
|
|
31
|
+
async verify(key) {
|
|
32
|
+
const keyHash = ApiKey.hashKey(key);
|
|
33
|
+
const apiKey = await this.findByHash(keyHash);
|
|
34
|
+
if (!apiKey) return null;
|
|
35
|
+
if (!apiKey.isValid()) return null;
|
|
36
|
+
await apiKey.recordUsage();
|
|
37
|
+
return apiKey;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Generate a new key for a profile
|
|
41
|
+
*/
|
|
42
|
+
async generateForProfile(profile, options) {
|
|
43
|
+
return await ApiKey.generate(profile, {
|
|
44
|
+
...options,
|
|
45
|
+
db: this.options.db
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Revoke all keys for a profile
|
|
50
|
+
*/
|
|
51
|
+
async revokeAllForProfile(profileId) {
|
|
52
|
+
const keys = await this.findActiveByProfile(profileId);
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
await key.revoke();
|
|
55
|
+
}
|
|
56
|
+
return keys.length;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Revoke a specific key by prefix
|
|
60
|
+
*/
|
|
61
|
+
async revokeByPrefix(keyPrefix) {
|
|
62
|
+
const key = await this.findOne({
|
|
63
|
+
where: { keyPrefix }
|
|
64
|
+
});
|
|
65
|
+
if (key && key.isValid()) {
|
|
66
|
+
await key.revoke();
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Clean up expired keys (soft delete by setting revokedAt)
|
|
73
|
+
*/
|
|
74
|
+
async cleanupExpired() {
|
|
75
|
+
const now = /* @__PURE__ */ new Date();
|
|
76
|
+
const allKeys = await this.list({});
|
|
77
|
+
let cleaned = 0;
|
|
78
|
+
for (const key of allKeys) {
|
|
79
|
+
if (key.expiresAt && key.expiresAt < now && !key.revokedAt) {
|
|
80
|
+
key.revokedAt = now;
|
|
81
|
+
await key.save();
|
|
82
|
+
cleaned++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return cleaned;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export {
|
|
89
|
+
ApiKeyCollection
|
|
90
|
+
};
|
|
91
|
+
//# sourceMappingURL=ApiKeyCollection-B6Op817e.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ApiKeyCollection-B6Op817e.js","sources":["../../src/collections/ApiKeyCollection.ts"],"sourcesContent":["/**\n * ApiKeyCollection - Collection for managing API keys\n */\n\nimport { SmrtCollection } from '@happyvertical/smrt-core';\nimport { ApiKey, type GenerateKeyResult } from '../models/ApiKey';\nimport type { Profile } from '../models/Profile';\n\nexport class ApiKeyCollection extends SmrtCollection<ApiKey> {\n static readonly _itemClass = ApiKey;\n\n /**\n * Find all keys for a profile\n */\n async findByProfile(profileId: string): Promise<ApiKey[]> {\n return await this.list({\n where: { profileId },\n });\n }\n\n /**\n * Find active (non-revoked, non-expired) keys for a profile\n */\n async findActiveByProfile(profileId: string): Promise<ApiKey[]> {\n const keys = await this.findByProfile(profileId);\n return keys.filter((key) => key.isValid());\n }\n\n /**\n * Find key by hash\n */\n async findByHash(keyHash: string): Promise<ApiKey | null> {\n return await this.findOne({\n where: { keyHash },\n });\n }\n\n /**\n * Verify an API key string and return the record if valid\n */\n async verify(key: string): Promise<ApiKey | null> {\n const keyHash = ApiKey.hashKey(key);\n const apiKey = await this.findByHash(keyHash);\n\n if (!apiKey) return null;\n if (!apiKey.isValid()) return null;\n\n // Record usage\n await apiKey.recordUsage();\n\n return apiKey;\n }\n\n /**\n * Generate a new key for a profile\n */\n async generateForProfile(\n profile: Profile,\n options: {\n name: string;\n scopes?: string[];\n expiresAt?: Date | null;\n },\n ): Promise<GenerateKeyResult> {\n return await ApiKey.generate(profile, {\n ...options,\n db: this.options.db,\n });\n }\n\n /**\n * Revoke all keys for a profile\n */\n async revokeAllForProfile(profileId: string): Promise<number> {\n const keys = await this.findActiveByProfile(profileId);\n for (const key of keys) {\n await key.revoke();\n }\n return keys.length;\n }\n\n /**\n * Revoke a specific key by prefix\n */\n async revokeByPrefix(keyPrefix: string): Promise<boolean> {\n const key = await this.findOne({\n where: { keyPrefix },\n });\n\n if (key && key.isValid()) {\n await key.revoke();\n return true;\n }\n return false;\n }\n\n /**\n * Clean up expired keys (soft delete by setting revokedAt)\n */\n async cleanupExpired(): Promise<number> {\n const now = new Date();\n const allKeys = await this.list({});\n let cleaned = 0;\n\n for (const key of allKeys) {\n if (key.expiresAt && key.expiresAt < now && !key.revokedAt) {\n key.revokedAt = now;\n await key.save();\n cleaned++;\n }\n }\n\n return cleaned;\n }\n}\n"],"names":[],"mappings":";;AAQO,MAAM,yBAAyB,eAAuB;AAAA,EAC3D,OAAgB,aAAa;AAAA;AAAA;AAAA;AAAA,EAK7B,MAAM,cAAc,WAAsC;AACxD,WAAO,MAAM,KAAK,KAAK;AAAA,MACrB,OAAO,EAAE,UAAA;AAAA,IAAU,CACpB;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,oBAAoB,WAAsC;AAC9D,UAAM,OAAO,MAAM,KAAK,cAAc,SAAS;AAC/C,WAAO,KAAK,OAAO,CAAC,QAAQ,IAAI,SAAS;AAAA,EAC3C;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,WAAW,SAAyC;AACxD,WAAO,MAAM,KAAK,QAAQ;AAAA,MACxB,OAAO,EAAE,QAAA;AAAA,IAAQ,CAClB;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,OAAO,KAAqC;AAChD,UAAM,UAAU,OAAO,QAAQ,GAAG;AAClC,UAAM,SAAS,MAAM,KAAK,WAAW,OAAO;AAE5C,QAAI,CAAC,OAAQ,QAAO;AACpB,QAAI,CAAC,OAAO,QAAA,EAAW,QAAO;AAG9B,UAAM,OAAO,YAAA;AAEb,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,mBACJ,SACA,SAK4B;AAC5B,WAAO,MAAM,OAAO,SAAS,SAAS;AAAA,MACpC,GAAG;AAAA,MACH,IAAI,KAAK,QAAQ;AAAA,IAAA,CAClB;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,oBAAoB,WAAoC;AAC5D,UAAM,OAAO,MAAM,KAAK,oBAAoB,SAAS;AACrD,eAAW,OAAO,MAAM;AACtB,YAAM,IAAI,OAAA;AAAA,IACZ;AACA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,eAAe,WAAqC;AACxD,UAAM,MAAM,MAAM,KAAK,QAAQ;AAAA,MAC7B,OAAO,EAAE,UAAA;AAAA,IAAU,CACpB;AAED,QAAI,OAAO,IAAI,WAAW;AACxB,YAAM,IAAI,OAAA;AACV,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,iBAAkC;AACtC,UAAM,0BAAU,KAAA;AAChB,UAAM,UAAU,MAAM,KAAK,KAAK,CAAA,CAAE;AAClC,QAAI,UAAU;AAEd,eAAW,OAAO,SAAS;AACzB,UAAI,IAAI,aAAa,IAAI,YAAY,OAAO,CAAC,IAAI,WAAW;AAC1D,YAAI,YAAY;AAChB,cAAM,IAAI,KAAA;AACV;AAAA,MACF;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AACF;"}
|