@loopwise/admin-sdk 0.1.0-beta.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 +20 -0
- package/README.md +58 -0
- package/dist/better-auth.d.mts +65 -0
- package/dist/better-auth.mjs +66 -0
- package/dist/index.d.mts +167 -0
- package/dist/index.mjs +197 -0
- package/package.json +67 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @loopwise/admin-sdk
|
|
2
|
+
|
|
3
|
+
## 0.1.0-beta.0
|
|
4
|
+
|
|
5
|
+
First public pre-release.
|
|
6
|
+
|
|
7
|
+
- `createAdminClient({ accessToken, refreshAccessToken?, baseUrl? })` returns a
|
|
8
|
+
`LoopwiseAdmin` interface with `.graphql<TData, TVars>()` typed pass-through
|
|
9
|
+
against `/admin/graphql`.
|
|
10
|
+
- Automatic 401 → `refreshAccessToken` → retry-once with the new token.
|
|
11
|
+
Refresh callback throws / returns empty → `LoopwiseError({ code: 'AUTH' })`.
|
|
12
|
+
- `.setAccessToken()` for out-of-band token refresh.
|
|
13
|
+
- First resource: `admin.courses.list({ perPage, page })` returns
|
|
14
|
+
`AdminCoursePage` (nodes + pagination meta).
|
|
15
|
+
- `/better-auth` sub-export: `loopwise({ clientId, clientSecret, audience?,
|
|
16
|
+
scopes?, baseUrl? })` factory delegating to better-auth's `genericOAuth`
|
|
17
|
+
plugin. PKCE on, `accessType: 'offline'`, RFC 8414 OAuth Authorization
|
|
18
|
+
Server Metadata discovery URL (`/.well-known/oauth-authorization-server`),
|
|
19
|
+
audience-aware providerId (`loopwise` / `loopwise-member`) and scope
|
|
20
|
+
defaults.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @loopwise/admin-sdk
|
|
2
|
+
|
|
3
|
+
Typed access to the Loopwise admin GraphQL API (`/admin/graphql`) for tenant-built applications.
|
|
4
|
+
|
|
5
|
+
Framework-agnostic core. Works in Node, Bun, Deno, Cloudflare Workers, Edge runtime, and the browser. Use the `/better-auth` sub-export for an opinionated Next.js + [better-auth](https://better-auth.com) integration that wires up OAuth + token refresh.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createAdminClient } from '@loopwise/admin-sdk';
|
|
11
|
+
|
|
12
|
+
const admin = createAdminClient({
|
|
13
|
+
accessToken: process.env.LOOPWISE_ACCESS_TOKEN!,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const { courses } = await admin.graphql<{ courses: { nodes: { id: string; name: string }[] } }>({
|
|
17
|
+
query: `{ courses(perPage: 20) { nodes { id name } } }`,
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## With better-auth (Next.js)
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// auth.ts
|
|
25
|
+
import { betterAuth } from 'better-auth';
|
|
26
|
+
import { loopwise } from '@loopwise/admin-sdk/better-auth';
|
|
27
|
+
|
|
28
|
+
export const auth = betterAuth({
|
|
29
|
+
database: /* your DB */,
|
|
30
|
+
plugins: [
|
|
31
|
+
loopwise({
|
|
32
|
+
clientId: process.env.LOOPWISE_CLIENT_ID!,
|
|
33
|
+
clientSecret: process.env.LOOPWISE_CLIENT_SECRET!,
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// app/courses/page.tsx
|
|
41
|
+
import { headers } from 'next/headers';
|
|
42
|
+
import { createAdminClient } from '@loopwise/admin-sdk';
|
|
43
|
+
import { auth } from '@/lib/auth';
|
|
44
|
+
|
|
45
|
+
export default async function Page() {
|
|
46
|
+
const { accessToken } = await auth.api.getAccessToken({
|
|
47
|
+
body: { providerId: 'loopwise' },
|
|
48
|
+
headers: headers(),
|
|
49
|
+
});
|
|
50
|
+
const admin = createAdminClient({ accessToken });
|
|
51
|
+
const { courses } = await admin.graphql<{ courses: { nodes: { id: string; name: string }[] } }>({
|
|
52
|
+
query: `{ courses(perPage: 20) { nodes { id name } } }`,
|
|
53
|
+
});
|
|
54
|
+
return <pre>{JSON.stringify(courses.nodes, null, 2)}</pre>;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
See [TEACH-18856](https://linear.app/kaik/issue/TEACH-18856) for the design rationale.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { BetterAuthPlugin } from "better-auth/types";
|
|
2
|
+
|
|
3
|
+
//#region src/better-auth.d.ts
|
|
4
|
+
type LoopwiseAuthAudience = 'org_admin' | 'member';
|
|
5
|
+
interface LoopwiseAuthOptions {
|
|
6
|
+
/** OAuth client ID issued at `/oauth/applications` in the school admin UI. */
|
|
7
|
+
clientId: string;
|
|
8
|
+
/** OAuth client secret. Server-only; never expose to the browser. */
|
|
9
|
+
clientSecret: string;
|
|
10
|
+
/**
|
|
11
|
+
* Token audience. Defaults to `'org_admin'` (school staff — non-student
|
|
12
|
+
* roles authorise this OAuth app). Use `'member'` for student/member-
|
|
13
|
+
* facing apps. The audience is checked at Doorkeeper authorize-time:
|
|
14
|
+
* wrong-identity users get redirected to login with the correct role.
|
|
15
|
+
*
|
|
16
|
+
* The OAuth application itself also has an `audience` field set at
|
|
17
|
+
* creation time; this option selects which provider identity ID
|
|
18
|
+
* better-auth uses (`'loopwise'` vs `'loopwise-member'`) so a tenant
|
|
19
|
+
* can register two apps and have both flows coexist.
|
|
20
|
+
*/
|
|
21
|
+
audience?: LoopwiseAuthAudience;
|
|
22
|
+
/**
|
|
23
|
+
* OAuth scopes to request. Defaults to a minimal-but-useful set per
|
|
24
|
+
* audience. Override to request more (e.g. add `courses:write`,
|
|
25
|
+
* `payments:read`) or to narrow further.
|
|
26
|
+
*/
|
|
27
|
+
scopes?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* Base URL of the Loopwise instance. Defaults to `https://app.loopwise.com`.
|
|
30
|
+
* Discovery doc fetched from
|
|
31
|
+
* `${baseUrl}/.well-known/oauth-authorization-server`.
|
|
32
|
+
*/
|
|
33
|
+
baseUrl?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Better-auth plugin for "log in with Loopwise". Wraps `genericOAuth` with
|
|
37
|
+
* Loopwise-specific defaults so tenants only need to supply credentials.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* import { betterAuth } from 'better-auth';
|
|
41
|
+
* import { loopwise } from '@loopwise/admin-sdk/better-auth';
|
|
42
|
+
*
|
|
43
|
+
* export const auth = betterAuth({
|
|
44
|
+
* database: ...,
|
|
45
|
+
* plugins: [
|
|
46
|
+
* loopwise({
|
|
47
|
+
* clientId: process.env.LOOPWISE_CLIENT_ID!,
|
|
48
|
+
* clientSecret: process.env.LOOPWISE_CLIENT_SECRET!,
|
|
49
|
+
* }),
|
|
50
|
+
* ],
|
|
51
|
+
* });
|
|
52
|
+
*
|
|
53
|
+
* @example Multi-audience (staff + member apps coexisting)
|
|
54
|
+
* plugins: [
|
|
55
|
+
* loopwise({ clientId: STAFF_ID, clientSecret: STAFF_SECRET }),
|
|
56
|
+
* loopwise({
|
|
57
|
+
* clientId: MEMBER_ID,
|
|
58
|
+
* clientSecret: MEMBER_SECRET,
|
|
59
|
+
* audience: 'member',
|
|
60
|
+
* }),
|
|
61
|
+
* ]
|
|
62
|
+
*/
|
|
63
|
+
declare function loopwise(options: LoopwiseAuthOptions): BetterAuthPlugin;
|
|
64
|
+
//#endregion
|
|
65
|
+
export { LoopwiseAuthAudience, LoopwiseAuthOptions, loopwise };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { genericOAuth } from "better-auth/plugins";
|
|
2
|
+
//#region src/better-auth.ts
|
|
3
|
+
const DEFAULT_BASE_URL = "https://app.loopwise.com";
|
|
4
|
+
const AUDIENCE_PROFILES = {
|
|
5
|
+
org_admin: {
|
|
6
|
+
providerId: "loopwise",
|
|
7
|
+
defaultScopes: [
|
|
8
|
+
"openid",
|
|
9
|
+
"profile",
|
|
10
|
+
"email",
|
|
11
|
+
"courses:read",
|
|
12
|
+
"members:read"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
member: {
|
|
16
|
+
providerId: "loopwise-member",
|
|
17
|
+
defaultScopes: [
|
|
18
|
+
"openid",
|
|
19
|
+
"profile",
|
|
20
|
+
"email"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Better-auth plugin for "log in with Loopwise". Wraps `genericOAuth` with
|
|
26
|
+
* Loopwise-specific defaults so tenants only need to supply credentials.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* import { betterAuth } from 'better-auth';
|
|
30
|
+
* import { loopwise } from '@loopwise/admin-sdk/better-auth';
|
|
31
|
+
*
|
|
32
|
+
* export const auth = betterAuth({
|
|
33
|
+
* database: ...,
|
|
34
|
+
* plugins: [
|
|
35
|
+
* loopwise({
|
|
36
|
+
* clientId: process.env.LOOPWISE_CLIENT_ID!,
|
|
37
|
+
* clientSecret: process.env.LOOPWISE_CLIENT_SECRET!,
|
|
38
|
+
* }),
|
|
39
|
+
* ],
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* @example Multi-audience (staff + member apps coexisting)
|
|
43
|
+
* plugins: [
|
|
44
|
+
* loopwise({ clientId: STAFF_ID, clientSecret: STAFF_SECRET }),
|
|
45
|
+
* loopwise({
|
|
46
|
+
* clientId: MEMBER_ID,
|
|
47
|
+
* clientSecret: MEMBER_SECRET,
|
|
48
|
+
* audience: 'member',
|
|
49
|
+
* }),
|
|
50
|
+
* ]
|
|
51
|
+
*/
|
|
52
|
+
function loopwise(options) {
|
|
53
|
+
const profile = AUDIENCE_PROFILES[options.audience ?? "org_admin"];
|
|
54
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
55
|
+
return genericOAuth({ config: [{
|
|
56
|
+
providerId: profile.providerId,
|
|
57
|
+
clientId: options.clientId,
|
|
58
|
+
clientSecret: options.clientSecret,
|
|
59
|
+
discoveryUrl: `${baseUrl}/.well-known/oauth-authorization-server`,
|
|
60
|
+
pkce: true,
|
|
61
|
+
accessType: "offline",
|
|
62
|
+
scopes: options.scopes ?? [...profile.defaultScopes]
|
|
63
|
+
}] });
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { loopwise };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
//#region src/config.d.ts
|
|
2
|
+
interface LoopwiseAdminConfig {
|
|
3
|
+
/**
|
|
4
|
+
* OAuth access token issued by Doorkeeper. Sent as `Authorization: Bearer
|
|
5
|
+
* <token>` on every request. Required — the admin GraphQL endpoint does
|
|
6
|
+
* not accept anonymous traffic.
|
|
7
|
+
*
|
|
8
|
+
* The token implicitly binds the request to a school (the OAuth
|
|
9
|
+
* application has `school_id` set at creation time in the school admin
|
|
10
|
+
* UI). Tenants do NOT pass a separate subdomain — server-side derives it.
|
|
11
|
+
*/
|
|
12
|
+
accessToken: string;
|
|
13
|
+
/**
|
|
14
|
+
* Async callback invoked when the upstream returns 401. Receives the
|
|
15
|
+
* current (expired) access token; returns a fresh one. The SDK retries
|
|
16
|
+
* the original request exactly once with the new token, then surfaces
|
|
17
|
+
* the result.
|
|
18
|
+
*
|
|
19
|
+
* Throw from inside to signal refresh failure — the original 401 is then
|
|
20
|
+
* mapped to `LoopwiseError({ code: 'AUTH' })`. When this callback is
|
|
21
|
+
* omitted, any 401 fails fast without retry.
|
|
22
|
+
*
|
|
23
|
+
* Typical wiring (with better-auth):
|
|
24
|
+
* refreshAccessToken: async () => {
|
|
25
|
+
* const { accessToken } = await auth.api.getAccessToken({
|
|
26
|
+
* body: { providerId: 'loopwise' }, headers: req.headers,
|
|
27
|
+
* });
|
|
28
|
+
* return accessToken;
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
refreshAccessToken?: (currentToken: string) => Promise<string>;
|
|
32
|
+
/**
|
|
33
|
+
* Base URL of the Loopwise instance. Defaults to `https://app.loopwise.com`.
|
|
34
|
+
* Override for staging (`https://staging.loopwise.com`) or self-hosted.
|
|
35
|
+
* The SDK appends `/admin/graphql` automatically.
|
|
36
|
+
*/
|
|
37
|
+
baseUrl?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Override the full GraphQL endpoint URL. You normally never set this —
|
|
40
|
+
* the SDK derives it from `baseUrl`. Use only for non-standard routing
|
|
41
|
+
* (proxy with path rewrite, etc.).
|
|
42
|
+
*/
|
|
43
|
+
endpoint?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Custom fetch implementation. Defaults to `globalThis.fetch`. Override
|
|
46
|
+
* for tests, proxy injection, or runtimes where `fetch` isn't global.
|
|
47
|
+
*/
|
|
48
|
+
fetch?: typeof fetch;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/client.d.ts
|
|
52
|
+
interface GraphQLRequest<TVars extends Record<string, unknown> = Record<string, unknown>> {
|
|
53
|
+
query: string;
|
|
54
|
+
variables?: TVars;
|
|
55
|
+
operationName?: string;
|
|
56
|
+
}
|
|
57
|
+
declare class LoopwiseAdminClient {
|
|
58
|
+
readonly endpoint: string;
|
|
59
|
+
private accessToken;
|
|
60
|
+
private readonly refreshAccessToken?;
|
|
61
|
+
private readonly fetchImpl;
|
|
62
|
+
private refreshInFlight;
|
|
63
|
+
constructor(config: LoopwiseAdminConfig);
|
|
64
|
+
graphql<TData, TVars extends Record<string, unknown> = Record<string, unknown>>(request: GraphQLRequest<TVars>): Promise<TData>;
|
|
65
|
+
setAccessToken(token: string): void;
|
|
66
|
+
/**
|
|
67
|
+
* Trigger or join an in-flight refresh. Resolves once `this.accessToken`
|
|
68
|
+
* holds the new token. Concurrent 401s share one round-trip.
|
|
69
|
+
*
|
|
70
|
+
* Contract: `callback` MUST eventually settle (resolve or reject). A
|
|
71
|
+
* promise that never settles leaves `refreshInFlight` set forever and
|
|
72
|
+
* subsequent 401s queue behind it indefinitely. Apply your own timeout
|
|
73
|
+
* around any network calls inside `refreshAccessToken`.
|
|
74
|
+
*/
|
|
75
|
+
private runRefresh;
|
|
76
|
+
private execute;
|
|
77
|
+
private parseResponse;
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/error.d.ts
|
|
81
|
+
type LoopwiseErrorCode = 'NETWORK' | 'AUTH' | 'GRAPHQL' | 'RATE_LIMIT' | 'CONFIG';
|
|
82
|
+
interface GraphQLError {
|
|
83
|
+
message: string;
|
|
84
|
+
path?: (string | number)[];
|
|
85
|
+
extensions?: Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
declare class LoopwiseError extends Error {
|
|
88
|
+
readonly code: LoopwiseErrorCode;
|
|
89
|
+
readonly endpoint?: string;
|
|
90
|
+
readonly status?: number;
|
|
91
|
+
readonly graphqlErrors?: GraphQLError[];
|
|
92
|
+
readonly requestId?: string;
|
|
93
|
+
constructor(code: LoopwiseErrorCode, message: string, detail?: {
|
|
94
|
+
endpoint?: string;
|
|
95
|
+
status?: number;
|
|
96
|
+
graphqlErrors?: GraphQLError[];
|
|
97
|
+
requestId?: string;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/resources/courses.d.ts
|
|
102
|
+
/**
|
|
103
|
+
* Subset of the admin `Course` GraphQL type exposed by the v1 SDK. Field
|
|
104
|
+
* names match the schema (no remapping) — `name` not `title`. Callers who
|
|
105
|
+
* need more fields can drop down to `admin.graphql()` directly.
|
|
106
|
+
*/
|
|
107
|
+
interface AdminCourse {
|
|
108
|
+
id: string;
|
|
109
|
+
name: string;
|
|
110
|
+
description: string | null;
|
|
111
|
+
image: string | null;
|
|
112
|
+
courseType: string;
|
|
113
|
+
contentType: string;
|
|
114
|
+
createdAt: number;
|
|
115
|
+
}
|
|
116
|
+
interface AdminCoursePage {
|
|
117
|
+
nodes: AdminCourse[];
|
|
118
|
+
nodesCount: number;
|
|
119
|
+
currentPage: number;
|
|
120
|
+
totalPages: number;
|
|
121
|
+
hasNextPage: boolean;
|
|
122
|
+
hasPreviousPage: boolean;
|
|
123
|
+
}
|
|
124
|
+
interface CoursesListArgs {
|
|
125
|
+
/** Items per page. Schema default 20, max 50. */
|
|
126
|
+
perPage?: number;
|
|
127
|
+
page?: number;
|
|
128
|
+
}
|
|
129
|
+
declare class CoursesResource {
|
|
130
|
+
private readonly client;
|
|
131
|
+
constructor(client: LoopwiseAdminClient);
|
|
132
|
+
/**
|
|
133
|
+
* List courses scoped to the school bound to the access token. Returns
|
|
134
|
+
* the full CoursePage shape (nodes + pagination meta).
|
|
135
|
+
*
|
|
136
|
+
* Requires OAuth scope `courses:read` on the access token.
|
|
137
|
+
*/
|
|
138
|
+
list(args?: CoursesListArgs): Promise<AdminCoursePage>;
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/index.d.ts
|
|
142
|
+
interface LoopwiseAdmin {
|
|
143
|
+
readonly courses: CoursesResource;
|
|
144
|
+
/**
|
|
145
|
+
* Run a typed GraphQL operation against `/admin/graphql`. The SDK does
|
|
146
|
+
* not parse or validate the document — pass the query string as-is and
|
|
147
|
+
* declare the expected response shape via `TData`.
|
|
148
|
+
*
|
|
149
|
+
* 401 responses trigger a single automatic token-refresh retry when
|
|
150
|
+
* `refreshAccessToken` is configured on the client.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* const { courses } = await admin.graphql<{ courses: { id: string }[] }>({
|
|
154
|
+
* query: `{ courses(perPage: 20) { id name } }`,
|
|
155
|
+
* });
|
|
156
|
+
*/
|
|
157
|
+
graphql<TData, TVars extends Record<string, unknown> = Record<string, unknown>>(request: GraphQLRequest<TVars>): Promise<TData>;
|
|
158
|
+
/**
|
|
159
|
+
* Replace the access token in-place. Use when the caller manages refresh
|
|
160
|
+
* out-of-band and wants subsequent calls to use the new token without
|
|
161
|
+
* rebuilding the client.
|
|
162
|
+
*/
|
|
163
|
+
setAccessToken(token: string): void;
|
|
164
|
+
}
|
|
165
|
+
declare function createAdminClient(config: LoopwiseAdminConfig): LoopwiseAdmin;
|
|
166
|
+
//#endregion
|
|
167
|
+
export { type AdminCourse, type AdminCoursePage, type CoursesListArgs, type GraphQLError, type GraphQLRequest, LoopwiseAdmin, type LoopwiseAdminConfig, LoopwiseError, type LoopwiseErrorCode, createAdminClient };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
//#region src/error.ts
|
|
2
|
+
var LoopwiseError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
endpoint;
|
|
5
|
+
status;
|
|
6
|
+
graphqlErrors;
|
|
7
|
+
requestId;
|
|
8
|
+
constructor(code, message, detail = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "LoopwiseError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.endpoint = detail.endpoint;
|
|
13
|
+
this.status = detail.status;
|
|
14
|
+
this.graphqlErrors = detail.graphqlErrors;
|
|
15
|
+
this.requestId = detail.requestId;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/client.ts
|
|
20
|
+
const DEFAULT_BASE_URL = "https://app.loopwise.com";
|
|
21
|
+
const BASE_HEADERS = {
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
accept: "application/graphql-response+json, application/json"
|
|
24
|
+
};
|
|
25
|
+
var LoopwiseAdminClient = class {
|
|
26
|
+
endpoint;
|
|
27
|
+
accessToken;
|
|
28
|
+
refreshAccessToken;
|
|
29
|
+
fetchImpl;
|
|
30
|
+
refreshInFlight = null;
|
|
31
|
+
constructor(config) {
|
|
32
|
+
if (!config.accessToken || !config.accessToken.trim()) throw new LoopwiseError("CONFIG", "accessToken is required. Pass it to createAdminClient({ accessToken }).");
|
|
33
|
+
this.accessToken = config.accessToken;
|
|
34
|
+
this.refreshAccessToken = config.refreshAccessToken;
|
|
35
|
+
this.endpoint = config.endpoint ?? resolveEndpoint(config.baseUrl);
|
|
36
|
+
if (config.fetch) this.fetchImpl = config.fetch;
|
|
37
|
+
else if (typeof globalThis.fetch === "function") this.fetchImpl = globalThis.fetch.bind(globalThis);
|
|
38
|
+
else throw new LoopwiseError("CONFIG", "globalThis.fetch is not available in this runtime. Pass a fetch implementation via createAdminClient({ fetch })");
|
|
39
|
+
}
|
|
40
|
+
async graphql(request) {
|
|
41
|
+
let response = await this.execute(request);
|
|
42
|
+
if (response.status === 401 && this.refreshAccessToken) {
|
|
43
|
+
await this.runRefresh(this.refreshAccessToken);
|
|
44
|
+
response = await this.execute(request);
|
|
45
|
+
}
|
|
46
|
+
return this.parseResponse(response);
|
|
47
|
+
}
|
|
48
|
+
setAccessToken(token) {
|
|
49
|
+
if (!token || !token.trim()) throw new LoopwiseError("CONFIG", "setAccessToken: token must be non-empty");
|
|
50
|
+
this.accessToken = token;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Trigger or join an in-flight refresh. Resolves once `this.accessToken`
|
|
54
|
+
* holds the new token. Concurrent 401s share one round-trip.
|
|
55
|
+
*
|
|
56
|
+
* Contract: `callback` MUST eventually settle (resolve or reject). A
|
|
57
|
+
* promise that never settles leaves `refreshInFlight` set forever and
|
|
58
|
+
* subsequent 401s queue behind it indefinitely. Apply your own timeout
|
|
59
|
+
* around any network calls inside `refreshAccessToken`.
|
|
60
|
+
*/
|
|
61
|
+
runRefresh(callback) {
|
|
62
|
+
if (!this.refreshInFlight) {
|
|
63
|
+
const tokenAtFailure = this.accessToken;
|
|
64
|
+
this.refreshInFlight = (async () => {
|
|
65
|
+
try {
|
|
66
|
+
let newToken;
|
|
67
|
+
try {
|
|
68
|
+
newToken = await callback(tokenAtFailure);
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
throw new LoopwiseError("AUTH", `Token refresh failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
71
|
+
endpoint: this.endpoint,
|
|
72
|
+
status: 401
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (!newToken || !newToken.trim()) throw new LoopwiseError("AUTH", "refreshAccessToken returned an empty token", {
|
|
76
|
+
endpoint: this.endpoint,
|
|
77
|
+
status: 401
|
|
78
|
+
});
|
|
79
|
+
this.accessToken = newToken;
|
|
80
|
+
} finally {
|
|
81
|
+
this.refreshInFlight = null;
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
85
|
+
return this.refreshInFlight;
|
|
86
|
+
}
|
|
87
|
+
async execute(request) {
|
|
88
|
+
try {
|
|
89
|
+
return await this.fetchImpl(this.endpoint, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
credentials: "omit",
|
|
92
|
+
headers: {
|
|
93
|
+
...BASE_HEADERS,
|
|
94
|
+
authorization: `Bearer ${this.accessToken}`
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
query: request.query,
|
|
98
|
+
variables: request.variables,
|
|
99
|
+
operationName: request.operationName
|
|
100
|
+
})
|
|
101
|
+
});
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
throw new LoopwiseError("NETWORK", `Failed to reach ${this.endpoint}: ${cause instanceof Error ? cause.message : String(cause)}`, { endpoint: this.endpoint });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async parseResponse(response) {
|
|
107
|
+
const requestId = response.headers.get("x-request-id") ?? void 0;
|
|
108
|
+
if (response.status === 401) throw new LoopwiseError("AUTH", "Unauthorized — token rejected by upstream", {
|
|
109
|
+
endpoint: this.endpoint,
|
|
110
|
+
status: 401,
|
|
111
|
+
requestId
|
|
112
|
+
});
|
|
113
|
+
if (response.status === 429) throw new LoopwiseError("RATE_LIMIT", "Rate limit exceeded", {
|
|
114
|
+
endpoint: this.endpoint,
|
|
115
|
+
status: 429,
|
|
116
|
+
requestId
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) throw new LoopwiseError("NETWORK", `Upstream returned HTTP ${response.status}`, {
|
|
119
|
+
endpoint: this.endpoint,
|
|
120
|
+
status: response.status,
|
|
121
|
+
requestId
|
|
122
|
+
});
|
|
123
|
+
let body;
|
|
124
|
+
try {
|
|
125
|
+
body = await response.json();
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
throw new LoopwiseError("NETWORK", `Upstream returned non-JSON (status ${response.status}): ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
128
|
+
endpoint: this.endpoint,
|
|
129
|
+
status: response.status,
|
|
130
|
+
requestId
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (body.errors && body.errors.length > 0) throw new LoopwiseError("GRAPHQL", body.errors[0].message, {
|
|
134
|
+
endpoint: this.endpoint,
|
|
135
|
+
status: response.status,
|
|
136
|
+
graphqlErrors: body.errors,
|
|
137
|
+
requestId
|
|
138
|
+
});
|
|
139
|
+
return body.data ?? null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
function resolveEndpoint(baseUrl) {
|
|
143
|
+
return `${(baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/admin/graphql`;
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/resources/courses.ts
|
|
147
|
+
const LIST_QUERY = `
|
|
148
|
+
query AdminCoursesList($perPage: Int, $page: Int) {
|
|
149
|
+
courses(perPage: $perPage, page: $page) {
|
|
150
|
+
nodes {
|
|
151
|
+
id
|
|
152
|
+
name
|
|
153
|
+
description
|
|
154
|
+
image
|
|
155
|
+
courseType
|
|
156
|
+
contentType
|
|
157
|
+
createdAt
|
|
158
|
+
}
|
|
159
|
+
nodesCount
|
|
160
|
+
currentPage
|
|
161
|
+
totalPages
|
|
162
|
+
hasNextPage
|
|
163
|
+
hasPreviousPage
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
`;
|
|
167
|
+
var CoursesResource = class {
|
|
168
|
+
client;
|
|
169
|
+
constructor(client) {
|
|
170
|
+
this.client = client;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* List courses scoped to the school bound to the access token. Returns
|
|
174
|
+
* the full CoursePage shape (nodes + pagination meta).
|
|
175
|
+
*
|
|
176
|
+
* Requires OAuth scope `courses:read` on the access token.
|
|
177
|
+
*/
|
|
178
|
+
async list(args = {}) {
|
|
179
|
+
return (await this.client.graphql({
|
|
180
|
+
query: LIST_QUERY,
|
|
181
|
+
variables: args,
|
|
182
|
+
operationName: "AdminCoursesList"
|
|
183
|
+
})).courses;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/index.ts
|
|
188
|
+
function createAdminClient(config) {
|
|
189
|
+
const client = new LoopwiseAdminClient(config);
|
|
190
|
+
return {
|
|
191
|
+
courses: new CoursesResource(client),
|
|
192
|
+
graphql: (request) => client.graphql(request),
|
|
193
|
+
setAccessToken: (token) => client.setAccessToken(token)
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
export { LoopwiseError, createAdminClient };
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@loopwise/admin-sdk",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Loopwise admin GraphQL SDK — typed access to /admin/graphql for tenant-built applications",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/kaikhq/manekineko.git",
|
|
8
|
+
"directory": "packages/admin-sdk"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://www.npmjs.com/package/@loopwise/admin-sdk",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/kaikhq/manekineko/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"loopwise",
|
|
16
|
+
"teachify",
|
|
17
|
+
"graphql",
|
|
18
|
+
"admin",
|
|
19
|
+
"oauth",
|
|
20
|
+
"sdk"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"main": "./dist/index.mjs",
|
|
25
|
+
"module": "./dist/index.mjs",
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.mts",
|
|
30
|
+
"import": "./dist/index.mjs"
|
|
31
|
+
},
|
|
32
|
+
"./better-auth": {
|
|
33
|
+
"types": "./dist/better-auth.d.mts",
|
|
34
|
+
"import": "./dist/better-auth.mjs"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist/*.mjs",
|
|
39
|
+
"dist/*.d.mts",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsdown",
|
|
45
|
+
"dev": "tsdown --watch",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"better-auth": ">=1.6.11"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"better-auth": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"better-auth": "1.6.11",
|
|
59
|
+
"tsconfig": "workspace:*",
|
|
60
|
+
"tsdown": "0.22.0",
|
|
61
|
+
"typescript": "5.9.3",
|
|
62
|
+
"vitest": "4.1.0"
|
|
63
|
+
},
|
|
64
|
+
"publishConfig": {
|
|
65
|
+
"access": "public"
|
|
66
|
+
}
|
|
67
|
+
}
|