@airdraft/cloud 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/CHANGELOG.md +5 -0
- package/dist/billing.d.ts +72 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +204 -0
- package/dist/billing.js.map +1 -0
- package/dist/callback.d.ts +39 -0
- package/dist/callback.d.ts.map +1 -0
- package/dist/callback.js +178 -0
- package/dist/callback.js.map +1 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/db.d.ts +13 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +23 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/invite.d.ts +28 -0
- package/dist/invite.d.ts.map +1 -0
- package/dist/invite.js +155 -0
- package/dist/invite.js.map +1 -0
- package/dist/jwt.d.ts +9 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +32 -0
- package/dist/jwt.js.map +1 -0
- package/dist/project.d.ts +30 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +145 -0
- package/dist/project.js.map +1 -0
- package/dist/relay.d.ts +35 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +101 -0
- package/dist/relay.js.map +1 -0
- package/dist/runtime.d.ts +48 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +186 -0
- package/dist/runtime.js.map +1 -0
- package/dist/state.d.ts +40 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +58 -0
- package/dist/state.js.map +1 -0
- package/dist/team.d.ts +31 -0
- package/dist/team.d.ts.map +1 -0
- package/dist/team.js +150 -0
- package/dist/team.js.map +1 -0
- package/dist/types.d.ts +161 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/webhook.d.ts +29 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +125 -0
- package/dist/webhook.js.map +1 -0
- package/package.json +45 -0
package/dist/relay.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { hashApiKey } from '@airdraft/auth';
|
|
2
|
+
import { signGitHubAppJwt } from './jwt.js';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
const GITHUB_API = 'https://api.github.com';
|
|
7
|
+
const CACHE_TTL_S = 55 * 60; // 55 minutes — 5 min buffer before GitHub's 1-hr expiry
|
|
8
|
+
const RATE_LIMIT_KEY = (installationId) => `ratelimit:github:token:${installationId}`;
|
|
9
|
+
const TOKEN_CACHE_KEY = (installationId) => `github:token:${installationId}`;
|
|
10
|
+
function json(body, status = 200) {
|
|
11
|
+
return new Response(JSON.stringify(body), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { 'Content-Type': 'application/json' },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// handleTokenRelay
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/**
|
|
20
|
+
* Handler for `POST /v1/github/token`.
|
|
21
|
+
*
|
|
22
|
+
* Exchanges a project API key (`ntk_*`) for a GitHub installation access token.
|
|
23
|
+
* Used by mode 2 self-hosted runtimes that rely on Airdraft's shared GitHub App.
|
|
24
|
+
*
|
|
25
|
+
* Flow:
|
|
26
|
+
* 1. Extract and validate `ntk_*` key from `Authorization: Bearer` header
|
|
27
|
+
* 2. Hash → look up in `projects` collection
|
|
28
|
+
* 3. Apply per-installation rate limit (1 req/s, Redis counter)
|
|
29
|
+
* 4. Return cached token from Redis if still fresh
|
|
30
|
+
* 5. Mint fresh token via GitHub App JWT → cache → return
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* // In a Next.js route handler:
|
|
34
|
+
* export async function POST(req: Request) {
|
|
35
|
+
* return handleTokenRelay(req, db, {
|
|
36
|
+
* githubAppId: process.env.GITHUB_APP_ID!,
|
|
37
|
+
* githubAppPrivateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
|
|
38
|
+
* cache: redisCache,
|
|
39
|
+
* })
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export async function handleTokenRelay(req, db, opts) {
|
|
44
|
+
// 1. Extract API key from Authorization header
|
|
45
|
+
const authHeader = req.headers.get('Authorization') ?? '';
|
|
46
|
+
const apiKey = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
|
|
47
|
+
if (!apiKey || !apiKey.startsWith('ntk_')) {
|
|
48
|
+
return json({ error: 'INVALID_KEY' }, 401);
|
|
49
|
+
}
|
|
50
|
+
// 2. Hash → project lookup
|
|
51
|
+
const hash = hashApiKey(apiKey);
|
|
52
|
+
const project = await db.projects.findOne({ apiKeyHash: hash });
|
|
53
|
+
if (!project)
|
|
54
|
+
return json({ error: 'INVALID_KEY' }, 401);
|
|
55
|
+
if (!project.github)
|
|
56
|
+
return json({ error: 'GITHUB_NOT_CONNECTED' }, 404);
|
|
57
|
+
const { installationId } = project.github;
|
|
58
|
+
// 3. Rate limit: 1 req/installation/sec via Redis counter
|
|
59
|
+
if (opts.cache) {
|
|
60
|
+
const rlKey = RATE_LIMIT_KEY(installationId);
|
|
61
|
+
const count = await opts.cache.get(rlKey);
|
|
62
|
+
if (count !== null && parseInt(count, 10) >= 1) {
|
|
63
|
+
return json({ error: 'RATE_LIMITED' }, 429);
|
|
64
|
+
}
|
|
65
|
+
await opts.cache.set(rlKey, '1', 1); // TTL: 1 second
|
|
66
|
+
}
|
|
67
|
+
// 4. L2 cache check
|
|
68
|
+
if (opts.cache) {
|
|
69
|
+
const cached = await opts.cache.get(TOKEN_CACHE_KEY(installationId));
|
|
70
|
+
if (cached) {
|
|
71
|
+
return json(JSON.parse(cached));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 5. Mint fresh token via GitHub App JWT
|
|
75
|
+
let githubRes;
|
|
76
|
+
try {
|
|
77
|
+
const jwt = await signGitHubAppJwt(opts.githubAppId, opts.githubAppPrivateKey);
|
|
78
|
+
githubRes = await fetch(`${GITHUB_API}/app/installations/${installationId}/access_tokens`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
Authorization: `Bearer ${jwt}`,
|
|
82
|
+
Accept: 'application/vnd.github+json',
|
|
83
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return json({ error: 'GITHUB_UNAVAILABLE' }, 503);
|
|
89
|
+
}
|
|
90
|
+
if (!githubRes.ok) {
|
|
91
|
+
return json({ error: 'GITHUB_UNAVAILABLE' }, 503);
|
|
92
|
+
}
|
|
93
|
+
const body = await githubRes.json();
|
|
94
|
+
const result = { token: body.token, expiresAt: body.expires_at };
|
|
95
|
+
// Cache for 55 minutes
|
|
96
|
+
if (opts.cache) {
|
|
97
|
+
await opts.cache.set(TOKEN_CACHE_KEY(installationId), JSON.stringify(result), CACHE_TTL_S);
|
|
98
|
+
}
|
|
99
|
+
return json(result);
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=relay.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay.js","sourceRoot":"","sources":["../src/relay.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CloudAuthAdapter } from '@airdraft/cloud-auth';
|
|
2
|
+
import type { CloudDb, TokenCache } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Evicts the cached CmsEngine for a project. Called by `handleWebhook` on
|
|
5
|
+
* push events so the next request picks up the updated schema immediately.
|
|
6
|
+
*/
|
|
7
|
+
export declare function bustEngineCache(projectId: string): void;
|
|
8
|
+
export interface CloudCmsOptions {
|
|
9
|
+
db: CloudDb;
|
|
10
|
+
/** TokenCache used for GitHub token caching AND schema caching. */
|
|
11
|
+
schemaCache: TokenCache;
|
|
12
|
+
githubAppId: string;
|
|
13
|
+
githubAppPrivateKey: string;
|
|
14
|
+
/**
|
|
15
|
+
* Cloud auth adapter. When provided, Bearer JWTs from cloud dashboard
|
|
16
|
+
* sessions are accepted alongside ntk_* API keys.
|
|
17
|
+
*/
|
|
18
|
+
authAdapter?: CloudAuthAdapter;
|
|
19
|
+
}
|
|
20
|
+
interface RouteContext {
|
|
21
|
+
params: Promise<{
|
|
22
|
+
projectSlug: string;
|
|
23
|
+
route?: string[];
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
type HandlerFn = (req: Request, ctx: RouteContext) => Promise<Response>;
|
|
27
|
+
export interface CloudCmsHandler {
|
|
28
|
+
GET: HandlerFn;
|
|
29
|
+
POST: HandlerFn;
|
|
30
|
+
PUT: HandlerFn;
|
|
31
|
+
PATCH: HandlerFn;
|
|
32
|
+
DELETE: HandlerFn;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Creates Next.js App Router route handlers for the cloud CMS runtime.
|
|
36
|
+
*
|
|
37
|
+
* Mount at `app/(cms)/[projectSlug]/api/cms/[...route]/route.ts`:
|
|
38
|
+
*
|
|
39
|
+
* ```ts
|
|
40
|
+
* const { GET, POST, PUT, DELETE } = createCloudCmsHandler({ db, schemaCache, githubAppId, githubAppPrivateKey })
|
|
41
|
+
* export { GET, POST, PUT, DELETE }
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* URL pattern: `cms.airdraft.space/{projectSlug}/api/cms/{collection}/{slug?}`
|
|
45
|
+
*/
|
|
46
|
+
export declare function createCloudCmsHandler(opts: CloudCmsOptions): CloudCmsHandler;
|
|
47
|
+
export {};
|
|
48
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAc,MAAM,YAAY,CAAA;AAejE;;;GAGG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAEvD;AAMD,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,OAAO,CAAA;IACX,mEAAmE;IACnE,WAAW,EAAE,UAAU,CAAA;IACvB,WAAW,EAAE,MAAM,CAAA;IACnB,mBAAmB,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,WAAW,CAAC,EAAE,gBAAgB,CAAA;CAC/B;AAED,UAAU,YAAY;IACpB,MAAM,EAAE,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAA;CAC3D;AAED,KAAK,SAAS,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,YAAY,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;AAEvE,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,SAAS,CAAA;IACd,IAAI,EAAE,SAAS,CAAA;IACf,GAAG,EAAE,SAAS,CAAA;IACd,KAAK,EAAE,SAAS,CAAA;IAChB,MAAM,EAAE,SAAS,CAAA;CAClB;AAwID;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe,CAwD5E"}
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { GitHubAdapter } from '@airdraft/core';
|
|
2
|
+
import { createCmsHandler } from '@airdraft/next';
|
|
3
|
+
import { withAuth } from '@airdraft/plugin-auth';
|
|
4
|
+
import { withAuditLog } from '@airdraft/plugin-audit-log';
|
|
5
|
+
import { hashApiKey } from '@airdraft/auth';
|
|
6
|
+
const _engineCache = new Map();
|
|
7
|
+
const ENGINE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
8
|
+
/**
|
|
9
|
+
* Evicts the cached CmsEngine for a project. Called by `handleWebhook` on
|
|
10
|
+
* push events so the next request picks up the updated schema immediately.
|
|
11
|
+
*/
|
|
12
|
+
export function bustEngineCache(projectId) {
|
|
13
|
+
_engineCache.delete(projectId);
|
|
14
|
+
}
|
|
15
|
+
const SCHEMA_CACHE_PREFIX = 'schema:';
|
|
16
|
+
function json(body, status = 200) {
|
|
17
|
+
return new Response(JSON.stringify(body), {
|
|
18
|
+
status,
|
|
19
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// buildProjectHandler
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
async function buildProjectHandler(project, opts) {
|
|
26
|
+
if (!project || !project.github)
|
|
27
|
+
throw new Error('Project or GitHub connection missing');
|
|
28
|
+
const { appId, repo, branch, installationId } = {
|
|
29
|
+
appId: opts.githubAppId,
|
|
30
|
+
repo: project.github.repo,
|
|
31
|
+
branch: project.github.branch,
|
|
32
|
+
installationId: String(project.github.installationId),
|
|
33
|
+
};
|
|
34
|
+
// Build GitHub adapter (direct JWT signing — cloud app owns the GitHub App)
|
|
35
|
+
const adapter = new GitHubAdapter({
|
|
36
|
+
appId,
|
|
37
|
+
privateKey: opts.githubAppPrivateKey,
|
|
38
|
+
installationId,
|
|
39
|
+
repo,
|
|
40
|
+
branch,
|
|
41
|
+
});
|
|
42
|
+
// Load schema JSON — check Redis cache first
|
|
43
|
+
const schemaCacheKey = `${SCHEMA_CACHE_PREFIX}${project._id.toString()}`;
|
|
44
|
+
let schemaJson = null;
|
|
45
|
+
if (opts.schemaCache) {
|
|
46
|
+
schemaJson = await opts.schemaCache.get(schemaCacheKey);
|
|
47
|
+
}
|
|
48
|
+
if (!schemaJson) {
|
|
49
|
+
const file = await adapter.read('airdraft.schema.json');
|
|
50
|
+
if (!file)
|
|
51
|
+
throw new Error('airdraft.schema.json not found in repository');
|
|
52
|
+
schemaJson = file.content;
|
|
53
|
+
await opts.schemaCache?.set(schemaCacheKey, schemaJson, 5 * 60); // 5-min TTL
|
|
54
|
+
}
|
|
55
|
+
const schema = JSON.parse(schemaJson);
|
|
56
|
+
// Build unified auth provider
|
|
57
|
+
const projectApiKeyHash = project.apiKeyHash;
|
|
58
|
+
const projectTeamId = project.teamId;
|
|
59
|
+
const db = opts.db;
|
|
60
|
+
const authAdapter = opts.authAdapter;
|
|
61
|
+
const authProvider = {
|
|
62
|
+
async verify(req) {
|
|
63
|
+
const authHeader = req.headers.get('Authorization') ?? '';
|
|
64
|
+
// 1. ntk_* API key — hash compare against stored hash
|
|
65
|
+
if (authHeader.startsWith('Bearer ntk_')) {
|
|
66
|
+
const key = authHeader.slice(7).trim();
|
|
67
|
+
if (projectApiKeyHash && hashApiKey(key) === projectApiKeyHash) {
|
|
68
|
+
return { id: 'api-key', email: 'api@key', name: 'API Key', role: 'admin' };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
// 2. Cloud session — Bearer JWT or httpOnly cookie via CloudAuthAdapter.
|
|
73
|
+
// Called regardless of Authorization header presence so that dashboard
|
|
74
|
+
// pages using same-origin cookie auth (no Bearer header) also pass.
|
|
75
|
+
if (authAdapter) {
|
|
76
|
+
const identity = await authAdapter.verifySession(req);
|
|
77
|
+
if (!identity)
|
|
78
|
+
return null;
|
|
79
|
+
const membership = await db.memberships.findOne({
|
|
80
|
+
teamId: projectTeamId,
|
|
81
|
+
userId: identity.userId,
|
|
82
|
+
});
|
|
83
|
+
if (!membership)
|
|
84
|
+
return null;
|
|
85
|
+
// Map cloud role to CMS role
|
|
86
|
+
const roleMap = {
|
|
87
|
+
owner: 'admin',
|
|
88
|
+
admin: 'admin',
|
|
89
|
+
editor: 'publisher',
|
|
90
|
+
viewer: 'editor',
|
|
91
|
+
};
|
|
92
|
+
const role = roleMap[membership.role] ?? 'editor';
|
|
93
|
+
return { id: identity.userId, email: identity.email, name: identity.name, role };
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
// Build CmsConfig — include MongoDB audit persister when auditEvents collection available
|
|
99
|
+
const plugins = [withAuth({ provider: authProvider })];
|
|
100
|
+
if (opts.db.auditEvents) {
|
|
101
|
+
const projectId = project._id.toString();
|
|
102
|
+
plugins.push(withAuditLog({
|
|
103
|
+
persist: async (event) => {
|
|
104
|
+
try {
|
|
105
|
+
await opts.db.auditEvents.insertOne({
|
|
106
|
+
...event,
|
|
107
|
+
projectId,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// audit log failures must never crash the CMS handler
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
const config = {
|
|
117
|
+
adapter,
|
|
118
|
+
collections: schema.collections,
|
|
119
|
+
plugins,
|
|
120
|
+
basePath: '/api/cms',
|
|
121
|
+
};
|
|
122
|
+
return createCmsHandler(config);
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// createCloudCmsHandler
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
/**
|
|
128
|
+
* Creates Next.js App Router route handlers for the cloud CMS runtime.
|
|
129
|
+
*
|
|
130
|
+
* Mount at `app/(cms)/[projectSlug]/api/cms/[...route]/route.ts`:
|
|
131
|
+
*
|
|
132
|
+
* ```ts
|
|
133
|
+
* const { GET, POST, PUT, DELETE } = createCloudCmsHandler({ db, schemaCache, githubAppId, githubAppPrivateKey })
|
|
134
|
+
* export { GET, POST, PUT, DELETE }
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* URL pattern: `cms.airdraft.space/{projectSlug}/api/cms/{collection}/{slug?}`
|
|
138
|
+
*/
|
|
139
|
+
export function createCloudCmsHandler(opts) {
|
|
140
|
+
async function handle(req, ctx) {
|
|
141
|
+
const params = await ctx.params;
|
|
142
|
+
const projectSlug = params.projectSlug;
|
|
143
|
+
const route = params.route ?? [];
|
|
144
|
+
// Load project by slug
|
|
145
|
+
const project = await opts.db.projects.findOne({ slug: projectSlug });
|
|
146
|
+
if (!project) {
|
|
147
|
+
return json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } }, 404);
|
|
148
|
+
}
|
|
149
|
+
if (!project.github) {
|
|
150
|
+
return json({ error: { code: 'GITHUB_NOT_CONNECTED', message: 'GitHub App not connected to this project' } }, 404);
|
|
151
|
+
}
|
|
152
|
+
// Get or build cached handler
|
|
153
|
+
const projectId = project._id.toString();
|
|
154
|
+
const cached = _engineCache.get(projectId);
|
|
155
|
+
let handler;
|
|
156
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
157
|
+
handler = cached.handler;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
try {
|
|
161
|
+
handler = await buildProjectHandler(project, opts);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
console.error('[airdraft/cloud] Failed to build engine for project', projectSlug, err);
|
|
165
|
+
return json({ error: { code: 'ENGINE_INIT_FAILED', message: 'Failed to initialize CMS engine' } }, 500);
|
|
166
|
+
}
|
|
167
|
+
_engineCache.set(projectId, { handler, expiresAt: Date.now() + ENGINE_TTL_MS });
|
|
168
|
+
}
|
|
169
|
+
// Delegate to the inner CMS handler, adapting the route context
|
|
170
|
+
const innerCtx = { params: Promise.resolve({ route }) };
|
|
171
|
+
const method = req.method.toUpperCase();
|
|
172
|
+
const routeHandler = handler[method];
|
|
173
|
+
if (!routeHandler) {
|
|
174
|
+
return json({ error: { code: 'METHOD_NOT_ALLOWED', message: `${method} not supported` } }, 405);
|
|
175
|
+
}
|
|
176
|
+
return routeHandler(req, innerCtx);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
GET: handle,
|
|
180
|
+
POST: handle,
|
|
181
|
+
PUT: handle,
|
|
182
|
+
PATCH: handle,
|
|
183
|
+
DELETE: handle,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"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"}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload embedded in the CSRF state token passed to GitHub during the
|
|
3
|
+
* App installation flow. Stateless — no DB storage needed.
|
|
4
|
+
*/
|
|
5
|
+
export interface StatePayload {
|
|
6
|
+
/** Project to associate the installation with. */
|
|
7
|
+
projectId: string;
|
|
8
|
+
/** User who initiated the connection — must still be admin at callback time. */
|
|
9
|
+
userId: string;
|
|
10
|
+
/** 32-byte hex nonce for replay resistance within the 10-minute window. */
|
|
11
|
+
nonce: string;
|
|
12
|
+
mode: 'dashboard' | 'cli';
|
|
13
|
+
/** CLI-only: local HTTP server port for receiving credentials. */
|
|
14
|
+
port?: number;
|
|
15
|
+
/** Unix ms expiry — 10-minute window. */
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
}
|
|
18
|
+
export type StateVerifyResult = {
|
|
19
|
+
valid: true;
|
|
20
|
+
payload: StatePayload;
|
|
21
|
+
} | {
|
|
22
|
+
valid: false;
|
|
23
|
+
reason: 'invalid_signature' | 'expired' | 'malformed';
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Signs a CSRF state token using HMAC-SHA256.
|
|
27
|
+
* Format: `<base64url(payload)>.<base64url(signature)>`
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* const state = signState({ projectId, userId, mode: 'dashboard' }, callbackSecret)
|
|
31
|
+
* const url = `https://github.com/apps/airdraft/installations/new?state=${state}`
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function signState(payload: Omit<StatePayload, 'nonce' | 'expiresAt'>, secret: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Verifies a CSRF state token. Returns the decoded payload on success,
|
|
37
|
+
* or a typed failure reason on any error.
|
|
38
|
+
*/
|
|
39
|
+
export declare function verifyState(token: string, secret: string): StateVerifyResult;
|
|
40
|
+
//# sourceMappingURL=state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAMA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAA;IACjB,gFAAgF;IAChF,MAAM,EAAE,MAAM,CAAA;IACd,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,WAAW,GAAG,KAAK,CAAA;IACzB,kEAAkE;IAClE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,yCAAyC;IACzC,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,YAAY,CAAA;CAAE,GACtC;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,mBAAmB,GAAG,SAAS,GAAG,WAAW,CAAA;CAAE,CAAA;AAQ3E;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CACvB,OAAO,EAAE,IAAI,CAAC,YAAY,EAAE,OAAO,GAAG,WAAW,CAAC,EAClD,MAAM,EAAE,MAAM,GACb,MAAM,CASR;AAMD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,iBAAiB,CA+B5E"}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Sign
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
/**
|
|
7
|
+
* Signs a CSRF state token using HMAC-SHA256.
|
|
8
|
+
* Format: `<base64url(payload)>.<base64url(signature)>`
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const state = signState({ projectId, userId, mode: 'dashboard' }, callbackSecret)
|
|
12
|
+
* const url = `https://github.com/apps/airdraft/installations/new?state=${state}`
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export function signState(payload, secret) {
|
|
16
|
+
const full = {
|
|
17
|
+
...payload,
|
|
18
|
+
nonce: randomBytes(32).toString('hex'),
|
|
19
|
+
expiresAt: Date.now() + STATE_TTL_MS,
|
|
20
|
+
};
|
|
21
|
+
const encoded = Buffer.from(JSON.stringify(full)).toString('base64url');
|
|
22
|
+
const sig = createHmac('sha256', secret).update(encoded).digest('base64url');
|
|
23
|
+
return `${encoded}.${sig}`;
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Verify
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* Verifies a CSRF state token. Returns the decoded payload on success,
|
|
30
|
+
* or a typed failure reason on any error.
|
|
31
|
+
*/
|
|
32
|
+
export function verifyState(token, secret) {
|
|
33
|
+
const dotIdx = token.lastIndexOf('.');
|
|
34
|
+
if (dotIdx === -1)
|
|
35
|
+
return { valid: false, reason: 'malformed' };
|
|
36
|
+
const encoded = token.slice(0, dotIdx);
|
|
37
|
+
const receivedSig = token.slice(dotIdx + 1);
|
|
38
|
+
// Constant-time signature comparison
|
|
39
|
+
const expectedSig = createHmac('sha256', secret).update(encoded).digest('base64url');
|
|
40
|
+
const expectedBuf = Buffer.from(expectedSig);
|
|
41
|
+
const receivedBuf = Buffer.from(receivedSig);
|
|
42
|
+
if (expectedBuf.length !== receivedBuf.length ||
|
|
43
|
+
!timingSafeEqual(expectedBuf, receivedBuf)) {
|
|
44
|
+
return { valid: false, reason: 'invalid_signature' };
|
|
45
|
+
}
|
|
46
|
+
let payload;
|
|
47
|
+
try {
|
|
48
|
+
payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { valid: false, reason: 'malformed' };
|
|
52
|
+
}
|
|
53
|
+
if (Date.now() > payload.expiresAt) {
|
|
54
|
+
return { valid: false, reason: 'expired' };
|
|
55
|
+
}
|
|
56
|
+
return { valid: true, payload };
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.js","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AA4BtE,MAAM,YAAY,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA,CAAC,aAAa;AAEjD,8EAA8E;AAC9E,OAAO;AACP,8EAA8E;AAE9E;;;;;;;;GAQG;AACH,MAAM,UAAU,SAAS,CACvB,OAAkD,EAClD,MAAc;IAEd,MAAM,IAAI,GAAiB;QACzB,GAAG,OAAO;QACV,KAAK,EAAE,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;QACtC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,YAAY;KACrC,CAAA;IACD,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;IACvE,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAA;IAC5E,OAAO,GAAG,OAAO,IAAI,GAAG,EAAE,CAAA;AAC5B,CAAC;AAED,8EAA8E;AAC9E,SAAS;AACT,8EAA8E;AAE9E;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,KAAa,EAAE,MAAc;IACvD,MAAM,MAAM,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;IACrC,IAAI,MAAM,KAAK,CAAC,CAAC;QAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,CAAA;IAE/D,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,CAAC,CAAA;IACtC,MAAM,WAAW,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IAE3C,qCAAqC;IACrC,MAAM,WAAW,GAAG,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAA;IACpF,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAC5C,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAE5C,IACE,WAAW,CAAC,MAAM,KAAK,WAAW,CAAC,MAAM;QACzC,CAAC,eAAe,CAAC,WAAW,EAAE,WAAW,CAAC,EAC1C,CAAC;QACD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,CAAA;IACtD,CAAC;IAED,IAAI,OAAqB,CAAA;IACzB,IAAI,CAAC;QACH,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAiB,CAAA;IAC1F,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,CAAA;IAC9C,CAAC;IAED,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;QACnC,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAA;IAC5C,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,CAAA;AACjC,CAAC"}
|
package/dist/team.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CloudAuthAdapter } from '@airdraft/cloud-auth';
|
|
2
|
+
import type { CloudDb } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* GET /v1/teams
|
|
5
|
+
*
|
|
6
|
+
* Returns all teams the authenticated user is a member of.
|
|
7
|
+
*/
|
|
8
|
+
export declare function handleListTeams(req: Request, db: CloudDb, auth: CloudAuthAdapter): Promise<Response>;
|
|
9
|
+
/**
|
|
10
|
+
* POST /v1/teams
|
|
11
|
+
*
|
|
12
|
+
* Body: `{ name: string }`
|
|
13
|
+
*
|
|
14
|
+
* Creates a new team and adds the creator as `owner`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function handleCreateTeam(req: Request, db: CloudDb, auth: CloudAuthAdapter): Promise<Response>;
|
|
17
|
+
/**
|
|
18
|
+
* GET /v1/teams/:teamSlug
|
|
19
|
+
*
|
|
20
|
+
* Returns team by slug. Requires membership.
|
|
21
|
+
*/
|
|
22
|
+
export declare function handleGetTeam(req: Request, db: CloudDb, auth: CloudAuthAdapter, teamSlug: string): Promise<Response>;
|
|
23
|
+
/**
|
|
24
|
+
* PATCH /v1/teams/:teamSlug
|
|
25
|
+
*
|
|
26
|
+
* Body: `{ name?: string }`
|
|
27
|
+
*
|
|
28
|
+
* Updates team metadata. Requires `owner` or `admin` role.
|
|
29
|
+
*/
|
|
30
|
+
export declare function handleUpdateTeam(req: Request, db: CloudDb, auth: CloudAuthAdapter, teamSlug: string): Promise<Response>;
|
|
31
|
+
//# sourceMappingURL=team.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"team.d.ts","sourceRoot":"","sources":["../src/team.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAqBzC;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,OAAO,EACZ,EAAE,EAAE,OAAO,EACX,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,QAAQ,CAAC,CAanB;AAMD;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,OAAO,EACZ,EAAE,EAAE,OAAO,EACX,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,QAAQ,CAAC,CA6CnB;AAMD;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,OAAO,EACZ,EAAE,EAAE,OAAO,EACX,IAAI,EAAE,gBAAgB,EACtB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,QAAQ,CAAC,CAcnB;AAMD;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,OAAO,EACZ,EAAE,EAAE,OAAO,EACX,IAAI,EAAE,gBAAgB,EACtB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,QAAQ,CAAC,CAkCnB"}
|
package/dist/team.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Helpers
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
function json(body, status = 200) {
|
|
5
|
+
return new Response(JSON.stringify(body), {
|
|
6
|
+
status,
|
|
7
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
function err(code, message, status) {
|
|
11
|
+
return json({ error: { code, message } }, status);
|
|
12
|
+
}
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// handleListTeams
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/**
|
|
17
|
+
* GET /v1/teams
|
|
18
|
+
*
|
|
19
|
+
* Returns all teams the authenticated user is a member of.
|
|
20
|
+
*/
|
|
21
|
+
export async function handleListTeams(req, db, auth) {
|
|
22
|
+
const identity = await auth.verifySession(req);
|
|
23
|
+
if (!identity)
|
|
24
|
+
return err('UNAUTHORIZED', 'Not authenticated', 401);
|
|
25
|
+
const memberships = await db.memberships.find({ userId: identity.userId }).toArray();
|
|
26
|
+
const teamIds = memberships.map((m) => m.teamId);
|
|
27
|
+
const { ObjectId } = await import('mongodb');
|
|
28
|
+
const teams = await db.teams
|
|
29
|
+
.find({ _id: { $in: teamIds.map((id) => new ObjectId(id)) } })
|
|
30
|
+
.toArray();
|
|
31
|
+
return json({ data: teams });
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// handleCreateTeam
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* POST /v1/teams
|
|
38
|
+
*
|
|
39
|
+
* Body: `{ name: string }`
|
|
40
|
+
*
|
|
41
|
+
* Creates a new team and adds the creator as `owner`.
|
|
42
|
+
*/
|
|
43
|
+
export async function handleCreateTeam(req, db, auth) {
|
|
44
|
+
const identity = await auth.verifySession(req);
|
|
45
|
+
if (!identity)
|
|
46
|
+
return err('UNAUTHORIZED', 'Not authenticated', 401);
|
|
47
|
+
let body;
|
|
48
|
+
try {
|
|
49
|
+
body = await req.json();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return err('INVALID_BODY', 'Request body must be JSON', 400);
|
|
53
|
+
}
|
|
54
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
55
|
+
if (!name)
|
|
56
|
+
return err('INVALID_NAME', 'Team name is required', 400);
|
|
57
|
+
// Generate URL-safe slug from name
|
|
58
|
+
const baseSlug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
59
|
+
if (!baseSlug)
|
|
60
|
+
return err('INVALID_NAME', 'Team name must contain alphanumeric characters', 400);
|
|
61
|
+
// Ensure slug uniqueness
|
|
62
|
+
const existing = await db.teams.findOne({ slug: baseSlug });
|
|
63
|
+
const slug = existing ? `${baseSlug}-${Date.now().toString(36)}` : baseSlug;
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const { ObjectId } = await import('mongodb');
|
|
66
|
+
const teamId = new ObjectId();
|
|
67
|
+
await db.teams.insertOne({
|
|
68
|
+
_id: teamId,
|
|
69
|
+
name,
|
|
70
|
+
slug,
|
|
71
|
+
ownerId: identity.userId,
|
|
72
|
+
planSlug: 'free',
|
|
73
|
+
createdAt: now,
|
|
74
|
+
});
|
|
75
|
+
await db.memberships.insertOne({
|
|
76
|
+
_id: new ObjectId(),
|
|
77
|
+
teamId: teamId.toString(),
|
|
78
|
+
userId: identity.userId,
|
|
79
|
+
role: 'owner',
|
|
80
|
+
createdAt: now,
|
|
81
|
+
});
|
|
82
|
+
const team = await db.teams.findOne({ _id: teamId });
|
|
83
|
+
return json({ data: team }, 201);
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// handleGetTeam
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/**
|
|
89
|
+
* GET /v1/teams/:teamSlug
|
|
90
|
+
*
|
|
91
|
+
* Returns team by slug. Requires membership.
|
|
92
|
+
*/
|
|
93
|
+
export async function handleGetTeam(req, db, auth, teamSlug) {
|
|
94
|
+
const identity = await auth.verifySession(req);
|
|
95
|
+
if (!identity)
|
|
96
|
+
return err('UNAUTHORIZED', 'Not authenticated', 401);
|
|
97
|
+
const team = await db.teams.findOne({ slug: teamSlug });
|
|
98
|
+
if (!team)
|
|
99
|
+
return err('TEAM_NOT_FOUND', 'Team not found', 404);
|
|
100
|
+
const membership = await db.memberships.findOne({
|
|
101
|
+
teamId: team._id.toString(),
|
|
102
|
+
userId: identity.userId,
|
|
103
|
+
});
|
|
104
|
+
if (!membership)
|
|
105
|
+
return err('FORBIDDEN', 'Not a member of this team', 403);
|
|
106
|
+
return json({ data: team });
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// handleUpdateTeam
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
/**
|
|
112
|
+
* PATCH /v1/teams/:teamSlug
|
|
113
|
+
*
|
|
114
|
+
* Body: `{ name?: string }`
|
|
115
|
+
*
|
|
116
|
+
* Updates team metadata. Requires `owner` or `admin` role.
|
|
117
|
+
*/
|
|
118
|
+
export async function handleUpdateTeam(req, db, auth, teamSlug) {
|
|
119
|
+
const identity = await auth.verifySession(req);
|
|
120
|
+
if (!identity)
|
|
121
|
+
return err('UNAUTHORIZED', 'Not authenticated', 401);
|
|
122
|
+
const team = await db.teams.findOne({ slug: teamSlug });
|
|
123
|
+
if (!team)
|
|
124
|
+
return err('TEAM_NOT_FOUND', 'Team not found', 404);
|
|
125
|
+
const membership = await db.memberships.findOne({
|
|
126
|
+
teamId: team._id.toString(),
|
|
127
|
+
userId: identity.userId,
|
|
128
|
+
});
|
|
129
|
+
if (!membership || !['owner', 'admin'].includes(membership.role)) {
|
|
130
|
+
return err('FORBIDDEN', 'Requires owner or admin role', 403);
|
|
131
|
+
}
|
|
132
|
+
let body;
|
|
133
|
+
try {
|
|
134
|
+
body = await req.json();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return err('INVALID_BODY', 'Request body must be JSON', 400);
|
|
138
|
+
}
|
|
139
|
+
const updates = {};
|
|
140
|
+
if (typeof body.name === 'string' && body.name.trim()) {
|
|
141
|
+
updates.name = body.name.trim();
|
|
142
|
+
}
|
|
143
|
+
if (Object.keys(updates).length === 0) {
|
|
144
|
+
return err('NO_CHANGES', 'No valid fields to update', 400);
|
|
145
|
+
}
|
|
146
|
+
await db.teams.updateOne({ _id: team._id }, { $set: updates });
|
|
147
|
+
const updated = await db.teams.findOne({ _id: team._id });
|
|
148
|
+
return json({ data: updated });
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=team.js.map
|