@vunexa/lixa 0.1.6-alpha.3 → 0.1.6-alpha.6
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 +277 -18
- package/README.template.md +277 -18
- package/dist/dao/types.d.ts +47 -0
- package/dist/dao/types.d.ts.map +1 -1
- package/dist/export-types/index.d.ts +95 -5
- package/dist/index.cjs +185 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +94 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +185 -12
- package/dist/index.js.map +1 -1
- package/dist/lixa.d.ts +37 -5
- package/dist/lixa.d.ts.map +1 -1
- package/dist/models/session.d.ts +2 -0
- package/dist/models/session.d.ts.map +1 -1
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -187,15 +187,16 @@ app.get("/connect/github/callback", async (req, res) => {
|
|
|
187
187
|
res.redirect("/dashboard");
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
-
// 3. Query Connected Resource Access Token
|
|
190
|
+
// 3. Query Connected Resource Access Token (Auto-Refreshes Expired Tokens)
|
|
191
191
|
app.get("/api/github/repos", async (req, res) => {
|
|
192
|
+
// Queries by active session OR user ID, auto-refreshing expired access tokens
|
|
192
193
|
const resource = await lixa.getConnectedResource(req.cookies.session_id, "github");
|
|
193
194
|
|
|
194
195
|
if (!resource) {
|
|
195
196
|
return res.status(403).json({ error: "GitHub resource not connected" });
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
// Call GitHub API with resource access token
|
|
199
|
+
// Call GitHub API with active resource access token
|
|
199
200
|
const response = await fetch("https://api.github.com/user/repos", {
|
|
200
201
|
headers: { Authorization: `Bearer ${resource.accessToken}` },
|
|
201
202
|
});
|
|
@@ -204,7 +205,10 @@ app.get("/api/github/repos", async (req, res) => {
|
|
|
204
205
|
res.json(repos);
|
|
205
206
|
});
|
|
206
207
|
|
|
207
|
-
// 4.
|
|
208
|
+
// 4. Query Resource Directly by User ID (e.g. inside background cron jobs or webhooks)
|
|
209
|
+
const githubResource = await lixa.getUserResource(userId, "github");
|
|
210
|
+
|
|
211
|
+
// 5. Disconnect Resource Provider
|
|
208
212
|
app.delete("/connect/github", async (req, res) => {
|
|
209
213
|
await lixa.disconnectResource(req.cookies.session_id, "github");
|
|
210
214
|
res.json({ success: true });
|
|
@@ -359,14 +363,6 @@ export const lixa = new Lixa({
|
|
|
359
363
|
"accessToken": "gho_8f7b2a9e1c3...",
|
|
360
364
|
"linkedAt": 1771657250000
|
|
361
365
|
}
|
|
362
|
-
},
|
|
363
|
-
"resources": {
|
|
364
|
-
"github": {
|
|
365
|
-
"provider": "github",
|
|
366
|
-
"accessToken": "gho_resource_repo_9a8b7c...",
|
|
367
|
-
"scopes": ["repo", "read:org"],
|
|
368
|
-
"connectedAt": 1771657300000
|
|
369
|
-
}
|
|
370
366
|
}
|
|
371
367
|
}
|
|
372
368
|
```
|
|
@@ -494,16 +490,279 @@ export const lixa = new Lixa({
|
|
|
494
490
|
"accessToken": "gho_8f7b2a9e1c3...",
|
|
495
491
|
"linkedAt": 1771657250000
|
|
496
492
|
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
## Custom Resource Storage Implementation (`ResourceStorage`)
|
|
501
|
+
|
|
502
|
+
While `SessionStorage` manages short-lived user authentication sessions (indexed by `sessionId`), **`ResourceStorage`** manages long-lived third-party API tokens (**AuthZ**) bound directly to a **User ID** (or Email). This ensures that third-party credentials (such as GitHub Repositories or Google Drive) persist across session expirations and logouts.
|
|
503
|
+
|
|
504
|
+
### `ResourceStorage` Interface
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
export interface ResourceStorage {
|
|
508
|
+
saveResource(userId: string, provider: string, resource: ConnectedResource): Promise<void>;
|
|
509
|
+
getResource(userId: string, provider: string): Promise<ConnectedResource | null>;
|
|
510
|
+
getUserResources(userId: string): Promise<Record<string, ConnectedResource>>;
|
|
511
|
+
deleteResource(userId: string, provider: string): Promise<void>;
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### 1. SQLite Resource Storage (`better-sqlite3`)
|
|
516
|
+
|
|
517
|
+
#### Table Schema (SQL DDL)
|
|
518
|
+
|
|
519
|
+
```sql
|
|
520
|
+
CREATE TABLE IF NOT EXISTS user_resources (
|
|
521
|
+
user_id TEXT NOT NULL,
|
|
522
|
+
provider TEXT NOT NULL,
|
|
523
|
+
data TEXT NOT NULL,
|
|
524
|
+
updated_at INTEGER NOT NULL,
|
|
525
|
+
PRIMARY KEY (user_id, provider)
|
|
526
|
+
);
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
#### TypeScript Implementation
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
import Database from "better-sqlite3";
|
|
533
|
+
import { Lixa, type ResourceStorage, type ConnectedResource } from "@vunexa/lixa";
|
|
534
|
+
|
|
535
|
+
export class SqliteResourceStorage implements ResourceStorage {
|
|
536
|
+
private db = new Database("lixa_resources.db");
|
|
537
|
+
|
|
538
|
+
constructor() {
|
|
539
|
+
this.db.exec(`
|
|
540
|
+
CREATE TABLE IF NOT EXISTS user_resources (
|
|
541
|
+
user_id TEXT NOT NULL,
|
|
542
|
+
provider TEXT NOT NULL,
|
|
543
|
+
data TEXT NOT NULL,
|
|
544
|
+
updated_at INTEGER NOT NULL,
|
|
545
|
+
PRIMARY KEY (user_id, provider)
|
|
546
|
+
);
|
|
547
|
+
`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async saveResource(userId: string, provider: string, resource: ConnectedResource): Promise<void> {
|
|
551
|
+
const stmt = this.db.prepare(`
|
|
552
|
+
INSERT INTO user_resources (user_id, provider, data, updated_at)
|
|
553
|
+
VALUES (?, ?, ?, ?)
|
|
554
|
+
ON CONFLICT(user_id, provider) DO UPDATE SET
|
|
555
|
+
data = excluded.data,
|
|
556
|
+
updated_at = excluded.updated_at
|
|
557
|
+
`);
|
|
558
|
+
stmt.run(userId, provider.toLowerCase(), JSON.stringify(resource), Date.now());
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async getResource(userId: string, provider: string): Promise<ConnectedResource | null> {
|
|
562
|
+
const stmt = this.db.prepare(`SELECT data FROM user_resources WHERE user_id = ? AND provider = ?`);
|
|
563
|
+
const row = stmt.get(userId, provider.toLowerCase()) as { data: string } | undefined;
|
|
564
|
+
return row ? (JSON.parse(row.data) as ConnectedResource) : null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async getUserResources(userId: string): Promise<Record<string, ConnectedResource>> {
|
|
568
|
+
const stmt = this.db.prepare(`SELECT provider, data FROM user_resources WHERE user_id = ?`);
|
|
569
|
+
const rows = stmt.all(userId) as Array<{ provider: string; data: string }>;
|
|
570
|
+
const result: Record<string, ConnectedResource> = {};
|
|
571
|
+
for (const r of rows) {
|
|
572
|
+
result[r.provider] = JSON.parse(r.data);
|
|
573
|
+
}
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async deleteResource(userId: string, provider: string): Promise<void> {
|
|
578
|
+
const stmt = this.db.prepare(`DELETE FROM user_resources WHERE user_id = ? AND provider = ?`);
|
|
579
|
+
stmt.run(userId, provider.toLowerCase());
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Pass to Lixa instance via resourceHandler
|
|
584
|
+
export const lixa = new Lixa({
|
|
585
|
+
resourceHandler: {
|
|
586
|
+
resourceStorage: new SqliteResourceStorage(),
|
|
587
|
+
},
|
|
588
|
+
providers: { /* ... */ },
|
|
589
|
+
});
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
#### Saved JSON Records Example in SQLite (`user_resources` Table)
|
|
593
|
+
|
|
594
|
+
**Row 1 (GitHub Repositories)**:
|
|
595
|
+
```json
|
|
596
|
+
{
|
|
597
|
+
"user_id": "1049281048",
|
|
598
|
+
"provider": "github",
|
|
599
|
+
"data": {
|
|
600
|
+
"accessToken": "gho_resource_repo_9a8b7c...",
|
|
601
|
+
"refreshToken": "ghr_refresh_token_123...",
|
|
602
|
+
"scopes": ["repo", "read:org"],
|
|
603
|
+
"expiresAt": 1771743600000,
|
|
604
|
+
"connectedAt": 1771657300000
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
**Row 2 (Google Drive)**:
|
|
610
|
+
```json
|
|
611
|
+
{
|
|
612
|
+
"user_id": "1049281048",
|
|
613
|
+
"provider": "google",
|
|
614
|
+
"data": {
|
|
615
|
+
"accessToken": "ya29.drive_resource_token_456...",
|
|
616
|
+
"refreshToken": "1//09abc_google_refresh_token...",
|
|
617
|
+
"scopes": ["https://www.googleapis.com/auth/drive.readonly"],
|
|
618
|
+
"expiresAt": 1771660800000,
|
|
619
|
+
"connectedAt": 1771657400000
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
---
|
|
625
|
+
|
|
626
|
+
### 2. AWS DynamoDB Resource Storage (`@aws-sdk/lib-dynamodb`)
|
|
627
|
+
|
|
628
|
+
For serverless AWS deployments storing user API credentials in DynamoDB:
|
|
629
|
+
|
|
630
|
+
#### Table Configuration
|
|
631
|
+
|
|
632
|
+
- **Table Name**: `LixaUserResources`
|
|
633
|
+
- **Partition Key (PK)**: `userId` (String)
|
|
634
|
+
- **Sort Key (SK)**: `provider` (String)
|
|
635
|
+
|
|
636
|
+
#### TypeScript Implementation
|
|
637
|
+
|
|
638
|
+
```typescript
|
|
639
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
640
|
+
import { DynamoDBDocumentClient, PutCommand, GetCommand, DeleteCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
|
|
641
|
+
import { Lixa, type ResourceStorage, type ConnectedResource } from "@vunexa/lixa";
|
|
642
|
+
|
|
643
|
+
export class DynamoDbResourceStorage implements ResourceStorage {
|
|
644
|
+
private docClient: DynamoDBDocumentClient;
|
|
645
|
+
private tableName = "LixaUserResources";
|
|
646
|
+
|
|
647
|
+
constructor() {
|
|
648
|
+
const client = new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" });
|
|
649
|
+
this.docClient = DynamoDBDocumentClient.from(client);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async saveResource(userId: string, provider: string, resource: ConnectedResource): Promise<void> {
|
|
653
|
+
await this.docClient.send(
|
|
654
|
+
new PutCommand({
|
|
655
|
+
TableName: this.tableName,
|
|
656
|
+
Item: {
|
|
657
|
+
userId,
|
|
658
|
+
provider: provider.toLowerCase(),
|
|
659
|
+
resourceData: resource,
|
|
660
|
+
updatedAt: Date.now(),
|
|
661
|
+
},
|
|
662
|
+
})
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async getResource(userId: string, provider: string): Promise<ConnectedResource | null> {
|
|
667
|
+
const res = await this.docClient.send(
|
|
668
|
+
new GetCommand({
|
|
669
|
+
TableName: this.tableName,
|
|
670
|
+
Key: {
|
|
671
|
+
userId,
|
|
672
|
+
provider: provider.toLowerCase(),
|
|
673
|
+
},
|
|
674
|
+
})
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
return res.Item ? (res.Item.resourceData as ConnectedResource) : null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async getUserResources(userId: string): Promise<Record<string, ConnectedResource>> {
|
|
681
|
+
const res = await this.docClient.send(
|
|
682
|
+
new QueryCommand({
|
|
683
|
+
TableName: this.tableName,
|
|
684
|
+
KeyConditionExpression: "userId = :userId",
|
|
685
|
+
ExpressionAttributeValues: { ":userId": userId },
|
|
686
|
+
})
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
const result: Record<string, ConnectedResource> = {};
|
|
690
|
+
if (res.Items) {
|
|
691
|
+
for (const item of res.Items) {
|
|
692
|
+
result[item.provider] = item.resourceData as ConnectedResource;
|
|
504
693
|
}
|
|
505
694
|
}
|
|
695
|
+
return result;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async deleteResource(userId: string, provider: string): Promise<void> {
|
|
699
|
+
await this.docClient.send(
|
|
700
|
+
new DeleteCommand({
|
|
701
|
+
TableName: this.tableName,
|
|
702
|
+
Key: {
|
|
703
|
+
userId,
|
|
704
|
+
provider: provider.toLowerCase(),
|
|
705
|
+
},
|
|
706
|
+
})
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Pass to Lixa instance via resourceHandler
|
|
712
|
+
export const lixa = new Lixa({
|
|
713
|
+
resourceHandler: {
|
|
714
|
+
resourceStorage: new DynamoDbResourceStorage(),
|
|
715
|
+
},
|
|
716
|
+
providers: { /* ... */ },
|
|
717
|
+
});
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
#### Saved DynamoDB Resource Items Example (`LixaUserResources` Table)
|
|
721
|
+
|
|
722
|
+
```json
|
|
723
|
+
[
|
|
724
|
+
{
|
|
725
|
+
"userId": "1049281048",
|
|
726
|
+
"provider": "github",
|
|
727
|
+
"updatedAt": 1771657300000,
|
|
728
|
+
"resourceData": {
|
|
729
|
+
"accessToken": "gho_resource_repo_9a8b7c...",
|
|
730
|
+
"refreshToken": "ghr_refresh_token_123...",
|
|
731
|
+
"scopes": ["repo", "read:org"],
|
|
732
|
+
"expiresAt": 1771743600000,
|
|
733
|
+
"connectedAt": 1771657300000
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
"userId": "1049281048",
|
|
738
|
+
"provider": "google",
|
|
739
|
+
"updatedAt": 1771657400000,
|
|
740
|
+
"resourceData": {
|
|
741
|
+
"accessToken": "ya29.drive_resource_token_456...",
|
|
742
|
+
"refreshToken": "1//09abc_google_refresh_token...",
|
|
743
|
+
"scopes": ["https://www.googleapis.com/auth/drive.readonly"],
|
|
744
|
+
"expiresAt": 1771660800000,
|
|
745
|
+
"connectedAt": 1771657400000
|
|
746
|
+
}
|
|
506
747
|
}
|
|
748
|
+
]
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
---
|
|
752
|
+
|
|
753
|
+
### 3. Querying Connected Resources Directly by User ID
|
|
754
|
+
|
|
755
|
+
`ResourceStorage` enables background workers, cron jobs, and webhooks to access third-party API credentials by `userId` without an active HTTP session:
|
|
756
|
+
|
|
757
|
+
```typescript
|
|
758
|
+
// Background worker querying user's GitHub Repos token
|
|
759
|
+
const githubResource = await lixa.getUserResource(user.id, "github");
|
|
760
|
+
|
|
761
|
+
if (githubResource) {
|
|
762
|
+
// Lixa auto-refreshes expired access tokens transparently!
|
|
763
|
+
const response = await fetch("https://api.github.com/user/repos", {
|
|
764
|
+
headers: { Authorization: `Bearer ${githubResource.accessToken}` },
|
|
765
|
+
});
|
|
507
766
|
}
|
|
508
767
|
```
|
|
509
768
|
|
package/README.template.md
CHANGED
|
@@ -277,15 +277,16 @@ app.get("/connect/github/callback", async (req, res) => {
|
|
|
277
277
|
res.redirect("/dashboard");
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
-
// 3. Query Connected Resource Access Token
|
|
280
|
+
// 3. Query Connected Resource Access Token (Auto-Refreshes Expired Tokens)
|
|
281
281
|
app.get("/api/github/repos", async (req, res) => {
|
|
282
|
+
// Queries by active session OR user ID, auto-refreshing expired access tokens
|
|
282
283
|
const resource = await lixa.getConnectedResource(req.cookies.session_id, "github");
|
|
283
284
|
|
|
284
285
|
if (!resource) {
|
|
285
286
|
return res.status(403).json({ error: "GitHub resource not connected" });
|
|
286
287
|
}
|
|
287
288
|
|
|
288
|
-
// Call GitHub API with resource access token
|
|
289
|
+
// Call GitHub API with active resource access token
|
|
289
290
|
const response = await fetch("https://api.github.com/user/repos", {
|
|
290
291
|
headers: { Authorization: `Bearer ${resource.accessToken}` },
|
|
291
292
|
});
|
|
@@ -294,7 +295,10 @@ app.get("/api/github/repos", async (req, res) => {
|
|
|
294
295
|
res.json(repos);
|
|
295
296
|
});
|
|
296
297
|
|
|
297
|
-
// 4.
|
|
298
|
+
// 4. Query Resource Directly by User ID (e.g. inside background cron jobs or webhooks)
|
|
299
|
+
const githubResource = await lixa.getUserResource(userId, "github");
|
|
300
|
+
|
|
301
|
+
// 5. Disconnect Resource Provider
|
|
298
302
|
app.delete("/connect/github", async (req, res) => {
|
|
299
303
|
await lixa.disconnectResource(req.cookies.session_id, "github");
|
|
300
304
|
res.json({ success: true });
|
|
@@ -449,14 +453,6 @@ export const lixa = new Lixa({
|
|
|
449
453
|
"accessToken": "gho_8f7b2a9e1c3...",
|
|
450
454
|
"linkedAt": 1771657250000
|
|
451
455
|
}
|
|
452
|
-
},
|
|
453
|
-
"resources": {
|
|
454
|
-
"github": {
|
|
455
|
-
"provider": "github",
|
|
456
|
-
"accessToken": "gho_resource_repo_9a8b7c...",
|
|
457
|
-
"scopes": ["repo", "read:org"],
|
|
458
|
-
"connectedAt": 1771657300000
|
|
459
|
-
}
|
|
460
456
|
}
|
|
461
457
|
}
|
|
462
458
|
```
|
|
@@ -584,16 +580,279 @@ export const lixa = new Lixa({
|
|
|
584
580
|
"accessToken": "gho_8f7b2a9e1c3...",
|
|
585
581
|
"linkedAt": 1771657250000
|
|
586
582
|
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## Custom Resource Storage Implementation (`ResourceStorage`)
|
|
591
|
+
|
|
592
|
+
While `SessionStorage` manages short-lived user authentication sessions (indexed by `sessionId`), **`ResourceStorage`** manages long-lived third-party API tokens (**AuthZ**) bound directly to a **User ID** (or Email). This ensures that third-party credentials (such as GitHub Repositories or Google Drive) persist across session expirations and logouts.
|
|
593
|
+
|
|
594
|
+
### `ResourceStorage` Interface
|
|
595
|
+
|
|
596
|
+
```typescript
|
|
597
|
+
export interface ResourceStorage {
|
|
598
|
+
saveResource(userId: string, provider: string, resource: ConnectedResource): Promise<void>;
|
|
599
|
+
getResource(userId: string, provider: string): Promise<ConnectedResource | null>;
|
|
600
|
+
getUserResources(userId: string): Promise<Record<string, ConnectedResource>>;
|
|
601
|
+
deleteResource(userId: string, provider: string): Promise<void>;
|
|
602
|
+
}
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### 1. SQLite Resource Storage (`better-sqlite3`)
|
|
606
|
+
|
|
607
|
+
#### Table Schema (SQL DDL)
|
|
608
|
+
|
|
609
|
+
```sql
|
|
610
|
+
CREATE TABLE IF NOT EXISTS user_resources (
|
|
611
|
+
user_id TEXT NOT NULL,
|
|
612
|
+
provider TEXT NOT NULL,
|
|
613
|
+
data TEXT NOT NULL,
|
|
614
|
+
updated_at INTEGER NOT NULL,
|
|
615
|
+
PRIMARY KEY (user_id, provider)
|
|
616
|
+
);
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
#### TypeScript Implementation
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
import Database from "better-sqlite3";
|
|
623
|
+
import { Lixa, type ResourceStorage, type ConnectedResource } from "@vunexa/lixa";
|
|
624
|
+
|
|
625
|
+
export class SqliteResourceStorage implements ResourceStorage {
|
|
626
|
+
private db = new Database("lixa_resources.db");
|
|
627
|
+
|
|
628
|
+
constructor() {
|
|
629
|
+
this.db.exec(`
|
|
630
|
+
CREATE TABLE IF NOT EXISTS user_resources (
|
|
631
|
+
user_id TEXT NOT NULL,
|
|
632
|
+
provider TEXT NOT NULL,
|
|
633
|
+
data TEXT NOT NULL,
|
|
634
|
+
updated_at INTEGER NOT NULL,
|
|
635
|
+
PRIMARY KEY (user_id, provider)
|
|
636
|
+
);
|
|
637
|
+
`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async saveResource(userId: string, provider: string, resource: ConnectedResource): Promise<void> {
|
|
641
|
+
const stmt = this.db.prepare(`
|
|
642
|
+
INSERT INTO user_resources (user_id, provider, data, updated_at)
|
|
643
|
+
VALUES (?, ?, ?, ?)
|
|
644
|
+
ON CONFLICT(user_id, provider) DO UPDATE SET
|
|
645
|
+
data = excluded.data,
|
|
646
|
+
updated_at = excluded.updated_at
|
|
647
|
+
`);
|
|
648
|
+
stmt.run(userId, provider.toLowerCase(), JSON.stringify(resource), Date.now());
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async getResource(userId: string, provider: string): Promise<ConnectedResource | null> {
|
|
652
|
+
const stmt = this.db.prepare(`SELECT data FROM user_resources WHERE user_id = ? AND provider = ?`);
|
|
653
|
+
const row = stmt.get(userId, provider.toLowerCase()) as { data: string } | undefined;
|
|
654
|
+
return row ? (JSON.parse(row.data) as ConnectedResource) : null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async getUserResources(userId: string): Promise<Record<string, ConnectedResource>> {
|
|
658
|
+
const stmt = this.db.prepare(`SELECT provider, data FROM user_resources WHERE user_id = ?`);
|
|
659
|
+
const rows = stmt.all(userId) as Array<{ provider: string; data: string }>;
|
|
660
|
+
const result: Record<string, ConnectedResource> = {};
|
|
661
|
+
for (const r of rows) {
|
|
662
|
+
result[r.provider] = JSON.parse(r.data);
|
|
663
|
+
}
|
|
664
|
+
return result;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async deleteResource(userId: string, provider: string): Promise<void> {
|
|
668
|
+
const stmt = this.db.prepare(`DELETE FROM user_resources WHERE user_id = ? AND provider = ?`);
|
|
669
|
+
stmt.run(userId, provider.toLowerCase());
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Pass to Lixa instance via resourceHandler
|
|
674
|
+
export const lixa = new Lixa({
|
|
675
|
+
resourceHandler: {
|
|
676
|
+
resourceStorage: new SqliteResourceStorage(),
|
|
677
|
+
},
|
|
678
|
+
providers: { /* ... */ },
|
|
679
|
+
});
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
#### Saved JSON Records Example in SQLite (`user_resources` Table)
|
|
683
|
+
|
|
684
|
+
**Row 1 (GitHub Repositories)**:
|
|
685
|
+
```json
|
|
686
|
+
{
|
|
687
|
+
"user_id": "1049281048",
|
|
688
|
+
"provider": "github",
|
|
689
|
+
"data": {
|
|
690
|
+
"accessToken": "gho_resource_repo_9a8b7c...",
|
|
691
|
+
"refreshToken": "ghr_refresh_token_123...",
|
|
692
|
+
"scopes": ["repo", "read:org"],
|
|
693
|
+
"expiresAt": 1771743600000,
|
|
694
|
+
"connectedAt": 1771657300000
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
**Row 2 (Google Drive)**:
|
|
700
|
+
```json
|
|
701
|
+
{
|
|
702
|
+
"user_id": "1049281048",
|
|
703
|
+
"provider": "google",
|
|
704
|
+
"data": {
|
|
705
|
+
"accessToken": "ya29.drive_resource_token_456...",
|
|
706
|
+
"refreshToken": "1//09abc_google_refresh_token...",
|
|
707
|
+
"scopes": ["https://www.googleapis.com/auth/drive.readonly"],
|
|
708
|
+
"expiresAt": 1771660800000,
|
|
709
|
+
"connectedAt": 1771657400000
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
---
|
|
715
|
+
|
|
716
|
+
### 2. AWS DynamoDB Resource Storage (`@aws-sdk/lib-dynamodb`)
|
|
717
|
+
|
|
718
|
+
For serverless AWS deployments storing user API credentials in DynamoDB:
|
|
719
|
+
|
|
720
|
+
#### Table Configuration
|
|
721
|
+
|
|
722
|
+
- **Table Name**: `LixaUserResources`
|
|
723
|
+
- **Partition Key (PK)**: `userId` (String)
|
|
724
|
+
- **Sort Key (SK)**: `provider` (String)
|
|
725
|
+
|
|
726
|
+
#### TypeScript Implementation
|
|
727
|
+
|
|
728
|
+
```typescript
|
|
729
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
730
|
+
import { DynamoDBDocumentClient, PutCommand, GetCommand, DeleteCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
|
|
731
|
+
import { Lixa, type ResourceStorage, type ConnectedResource } from "@vunexa/lixa";
|
|
732
|
+
|
|
733
|
+
export class DynamoDbResourceStorage implements ResourceStorage {
|
|
734
|
+
private docClient: DynamoDBDocumentClient;
|
|
735
|
+
private tableName = "LixaUserResources";
|
|
736
|
+
|
|
737
|
+
constructor() {
|
|
738
|
+
const client = new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" });
|
|
739
|
+
this.docClient = DynamoDBDocumentClient.from(client);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async saveResource(userId: string, provider: string, resource: ConnectedResource): Promise<void> {
|
|
743
|
+
await this.docClient.send(
|
|
744
|
+
new PutCommand({
|
|
745
|
+
TableName: this.tableName,
|
|
746
|
+
Item: {
|
|
747
|
+
userId,
|
|
748
|
+
provider: provider.toLowerCase(),
|
|
749
|
+
resourceData: resource,
|
|
750
|
+
updatedAt: Date.now(),
|
|
751
|
+
},
|
|
752
|
+
})
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async getResource(userId: string, provider: string): Promise<ConnectedResource | null> {
|
|
757
|
+
const res = await this.docClient.send(
|
|
758
|
+
new GetCommand({
|
|
759
|
+
TableName: this.tableName,
|
|
760
|
+
Key: {
|
|
761
|
+
userId,
|
|
762
|
+
provider: provider.toLowerCase(),
|
|
763
|
+
},
|
|
764
|
+
})
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
return res.Item ? (res.Item.resourceData as ConnectedResource) : null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async getUserResources(userId: string): Promise<Record<string, ConnectedResource>> {
|
|
771
|
+
const res = await this.docClient.send(
|
|
772
|
+
new QueryCommand({
|
|
773
|
+
TableName: this.tableName,
|
|
774
|
+
KeyConditionExpression: "userId = :userId",
|
|
775
|
+
ExpressionAttributeValues: { ":userId": userId },
|
|
776
|
+
})
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
const result: Record<string, ConnectedResource> = {};
|
|
780
|
+
if (res.Items) {
|
|
781
|
+
for (const item of res.Items) {
|
|
782
|
+
result[item.provider] = item.resourceData as ConnectedResource;
|
|
594
783
|
}
|
|
595
784
|
}
|
|
785
|
+
return result;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async deleteResource(userId: string, provider: string): Promise<void> {
|
|
789
|
+
await this.docClient.send(
|
|
790
|
+
new DeleteCommand({
|
|
791
|
+
TableName: this.tableName,
|
|
792
|
+
Key: {
|
|
793
|
+
userId,
|
|
794
|
+
provider: provider.toLowerCase(),
|
|
795
|
+
},
|
|
796
|
+
})
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Pass to Lixa instance via resourceHandler
|
|
802
|
+
export const lixa = new Lixa({
|
|
803
|
+
resourceHandler: {
|
|
804
|
+
resourceStorage: new DynamoDbResourceStorage(),
|
|
805
|
+
},
|
|
806
|
+
providers: { /* ... */ },
|
|
807
|
+
});
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
#### Saved DynamoDB Resource Items Example (`LixaUserResources` Table)
|
|
811
|
+
|
|
812
|
+
```json
|
|
813
|
+
[
|
|
814
|
+
{
|
|
815
|
+
"userId": "1049281048",
|
|
816
|
+
"provider": "github",
|
|
817
|
+
"updatedAt": 1771657300000,
|
|
818
|
+
"resourceData": {
|
|
819
|
+
"accessToken": "gho_resource_repo_9a8b7c...",
|
|
820
|
+
"refreshToken": "ghr_refresh_token_123...",
|
|
821
|
+
"scopes": ["repo", "read:org"],
|
|
822
|
+
"expiresAt": 1771743600000,
|
|
823
|
+
"connectedAt": 1771657300000
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
"userId": "1049281048",
|
|
828
|
+
"provider": "google",
|
|
829
|
+
"updatedAt": 1771657400000,
|
|
830
|
+
"resourceData": {
|
|
831
|
+
"accessToken": "ya29.drive_resource_token_456...",
|
|
832
|
+
"refreshToken": "1//09abc_google_refresh_token...",
|
|
833
|
+
"scopes": ["https://www.googleapis.com/auth/drive.readonly"],
|
|
834
|
+
"expiresAt": 1771660800000,
|
|
835
|
+
"connectedAt": 1771657400000
|
|
836
|
+
}
|
|
596
837
|
}
|
|
838
|
+
]
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
---
|
|
842
|
+
|
|
843
|
+
### 3. Querying Connected Resources Directly by User ID
|
|
844
|
+
|
|
845
|
+
`ResourceStorage` enables background workers, cron jobs, and webhooks to access third-party API credentials by `userId` without an active HTTP session:
|
|
846
|
+
|
|
847
|
+
```typescript
|
|
848
|
+
// Background worker querying user's GitHub Repos token
|
|
849
|
+
const githubResource = await lixa.getUserResource(user.id, "github");
|
|
850
|
+
|
|
851
|
+
if (githubResource) {
|
|
852
|
+
// Lixa auto-refreshes expired access tokens transparently!
|
|
853
|
+
const response = await fetch("https://api.github.com/user/repos", {
|
|
854
|
+
headers: { Authorization: `Bearer ${githubResource.accessToken}` },
|
|
855
|
+
});
|
|
597
856
|
}
|
|
598
857
|
```
|
|
599
858
|
|