@mizchi/k1c 0.3.0 → 0.4.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.
@@ -308,6 +308,35 @@ const ingressRuleSchema = z.object({
308
308
  host: z.string().min(1).optional(),
309
309
  http: z.object({ paths: z.array(ingressPathSchema).min(1) }),
310
310
  });
311
+ const accessRuleSchema = z.union([
312
+ z.object({ email: z.object({ email: z.string().min(1) }) }),
313
+ z.object({ emailDomain: z.object({ domain: z.string().min(1) }) }),
314
+ z.object({ everyone: z.object({}).strict() }),
315
+ z.object({ ip: z.object({ ip: z.string().min(1) }) }),
316
+ z.object({ country: z.object({ code: z.string().min(2) }) }),
317
+ z.object({ serviceToken: z.object({ tokenId: z.string().min(1) }) }),
318
+ z.object({ anyValidServiceToken: z.object({}).strict() }),
319
+ ]);
320
+ const accessAppPolicySchema = z.object({
321
+ name: z.string().min(1),
322
+ decision: z.enum(['allow', 'deny', 'bypass', 'non_identity']),
323
+ include: z.array(accessRuleSchema).min(1),
324
+ exclude: z.array(accessRuleSchema).optional(),
325
+ require: z.array(accessRuleSchema).optional(),
326
+ sessionDuration: z.string().optional(),
327
+ });
328
+ export const accessApplicationSchema = z.object({
329
+ apiVersion: z.literal('cloudflare.k1c.io/v1alpha1'),
330
+ kind: z.literal('AccessApplication'),
331
+ metadata: objectMetaSchema,
332
+ spec: z.object({
333
+ domain: z.string().min(1),
334
+ sessionDuration: z.string().optional(),
335
+ autoRedirectToIdentity: z.boolean().optional(),
336
+ allowedIdps: z.array(z.string()).optional(),
337
+ policies: z.array(accessAppPolicySchema).min(1),
338
+ }),
339
+ });
311
340
  export const ingressSchema = z.object({
312
341
  apiVersion: z.literal('networking.k8s.io/v1'),
313
342
  kind: z.literal('Ingress'),
@@ -337,5 +366,6 @@ export const k1cResourceSchema = z.discriminatedUnion('kind', [
337
366
  dnsRecordSchema,
338
367
  logpushJobSchema,
339
368
  ingressSchema,
369
+ accessApplicationSchema,
340
370
  ]);
341
371
  //# sourceMappingURL=schemas.js.map
@@ -265,7 +265,49 @@ export interface IngressSpec {
265
265
  readonly defaultBackend?: IngressBackend;
266
266
  }
267
267
  export type Ingress = BaseResource<'Ingress', 'networking.k8s.io/v1', IngressSpec>;
268
- export type K1cResource = Deployment | Rollout | StatefulSet | CronJob | Job | ConfigMapResource | SecretResource | NamespaceResource | ServiceResource | R2Bucket | KVNamespace | DispatchNamespace | Hyperdrive | D1Database | Queue | Vectorize | DNSRecord | LogpushJob | Ingress;
268
+ export type AccessDecision = 'allow' | 'deny' | 'bypass' | 'non_identity';
269
+ export type AccessRule = {
270
+ readonly email: {
271
+ readonly email: string;
272
+ };
273
+ } | {
274
+ readonly emailDomain: {
275
+ readonly domain: string;
276
+ };
277
+ } | {
278
+ readonly everyone: Readonly<Record<string, never>>;
279
+ } | {
280
+ readonly ip: {
281
+ readonly ip: string;
282
+ };
283
+ } | {
284
+ readonly country: {
285
+ readonly code: string;
286
+ };
287
+ } | {
288
+ readonly serviceToken: {
289
+ readonly tokenId: string;
290
+ };
291
+ } | {
292
+ readonly anyValidServiceToken: Readonly<Record<string, never>>;
293
+ };
294
+ export interface AccessAppPolicy {
295
+ readonly name: string;
296
+ readonly decision: AccessDecision;
297
+ readonly include: ReadonlyArray<AccessRule>;
298
+ readonly exclude?: ReadonlyArray<AccessRule>;
299
+ readonly require?: ReadonlyArray<AccessRule>;
300
+ readonly sessionDuration?: string;
301
+ }
302
+ export interface AccessApplicationSpec {
303
+ readonly domain: string;
304
+ readonly sessionDuration?: string;
305
+ readonly autoRedirectToIdentity?: boolean;
306
+ readonly allowedIdps?: ReadonlyArray<string>;
307
+ readonly policies: ReadonlyArray<AccessAppPolicy>;
308
+ }
309
+ export type AccessApplication = BaseResource<'AccessApplication', 'cloudflare.k1c.io/v1alpha1', AccessApplicationSpec>;
310
+ export type K1cResource = Deployment | Rollout | StatefulSet | CronJob | Job | ConfigMapResource | SecretResource | NamespaceResource | ServiceResource | R2Bucket | KVNamespace | DispatchNamespace | Hyperdrive | D1Database | Queue | Vectorize | DNSRecord | LogpushJob | Ingress | AccessApplication;
269
311
  export type ResourceKind = K1cResource['kind'];
270
312
  export interface ResourceRef {
271
313
  readonly apiVersion: string;
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+ import type { CloudflareResourceProvider } from './types.ts';
3
+ export type AccessDecisionWire = 'allow' | 'deny' | 'bypass' | 'non_identity';
4
+ /**
5
+ * SDK-shaped (snake_case) Access rule. The lowering layer translates the camelCase
6
+ * manifest form into this once and the provider then ferries it across the API
7
+ * verbatim. Keeping a flat wire shape here makes the discriminated union match
8
+ * Cloudflare's response without a second translation step.
9
+ */
10
+ export type AccessRuleWire = {
11
+ readonly email: {
12
+ readonly email: string;
13
+ };
14
+ } | {
15
+ readonly email_domain: {
16
+ readonly domain: string;
17
+ };
18
+ } | {
19
+ readonly everyone: Readonly<Record<string, never>>;
20
+ } | {
21
+ readonly ip: {
22
+ readonly ip: string;
23
+ };
24
+ } | {
25
+ readonly country: {
26
+ readonly country_code: string;
27
+ };
28
+ } | {
29
+ readonly service_token: {
30
+ readonly token_id: string;
31
+ };
32
+ } | {
33
+ readonly any_valid_service_token: Readonly<Record<string, never>>;
34
+ };
35
+ export interface AccessAppPolicyWire {
36
+ readonly name: string;
37
+ readonly decision: AccessDecisionWire;
38
+ readonly include: ReadonlyArray<AccessRuleWire>;
39
+ readonly exclude?: ReadonlyArray<AccessRuleWire>;
40
+ readonly require?: ReadonlyArray<AccessRuleWire>;
41
+ readonly session_duration?: string;
42
+ }
43
+ export interface AccessApplicationProperties {
44
+ readonly appName: string;
45
+ readonly domain: string;
46
+ readonly sessionDuration?: string;
47
+ readonly autoRedirectToIdentity?: boolean;
48
+ readonly allowedIdps?: ReadonlyArray<string>;
49
+ readonly policies: ReadonlyArray<AccessAppPolicyWire>;
50
+ }
51
+ export declare const accessApplicationSchema: z.ZodType<AccessApplicationProperties>;
52
+ export declare const accessApplicationProvider: CloudflareResourceProvider<AccessApplicationProperties>;
53
+ //# sourceMappingURL=access-application.d.ts.map
@@ -0,0 +1,183 @@
1
+ import { z } from 'zod';
2
+ import { NotFound } from "./types.js";
3
+ import { toProviderError } from "./errors.js";
4
+ const accessRuleWireSchema = z.union([
5
+ z.object({ email: z.object({ email: z.string() }) }),
6
+ z.object({ email_domain: z.object({ domain: z.string() }) }),
7
+ z.object({ everyone: z.object({}).strict() }),
8
+ z.object({ ip: z.object({ ip: z.string() }) }),
9
+ z.object({ country: z.object({ country_code: z.string() }) }),
10
+ z.object({ service_token: z.object({ token_id: z.string() }) }),
11
+ z.object({ any_valid_service_token: z.object({}).strict() }),
12
+ ]);
13
+ const accessAppPolicyWireSchema = z.object({
14
+ name: z.string(),
15
+ decision: z.enum(['allow', 'deny', 'bypass', 'non_identity']),
16
+ include: z.array(accessRuleWireSchema),
17
+ exclude: z.array(accessRuleWireSchema).optional(),
18
+ require: z.array(accessRuleWireSchema).optional(),
19
+ session_duration: z.string().optional(),
20
+ });
21
+ export const accessApplicationSchema = z.object({
22
+ appName: z.string(),
23
+ domain: z.string(),
24
+ sessionDuration: z.string().optional(),
25
+ autoRedirectToIdentity: z.boolean().optional(),
26
+ allowedIdps: z.array(z.string()).optional(),
27
+ policies: z.array(accessAppPolicyWireSchema),
28
+ });
29
+ const NAME_PREFIX = 'k1c-';
30
+ /**
31
+ * Cloudflare Access Applications carry no per-app comment field, so ownership is
32
+ * inferred from the application name starting with the `k1c-` prefix. This is
33
+ * the same approach used by other prefix-named resources.
34
+ */
35
+ function parseLabel(name) {
36
+ if (typeof name !== 'string' || !name.startsWith(NAME_PREFIX))
37
+ return null;
38
+ const rest = name.slice(NAME_PREFIX.length);
39
+ const dash = rest.indexOf('-');
40
+ if (dash <= 0 || dash === rest.length - 1)
41
+ return null;
42
+ return `${rest.slice(0, dash)}/${rest.slice(dash + 1)}`;
43
+ }
44
+ function buildBody(props) {
45
+ return {
46
+ name: props.appName,
47
+ domain: props.domain,
48
+ type: 'self_hosted',
49
+ ...(props.sessionDuration !== undefined ? { session_duration: props.sessionDuration } : {}),
50
+ ...(props.autoRedirectToIdentity !== undefined
51
+ ? { auto_redirect_to_identity: props.autoRedirectToIdentity }
52
+ : {}),
53
+ ...(props.allowedIdps !== undefined ? { allowed_idps: [...props.allowedIdps] } : {}),
54
+ policies: props.policies.map((p) => ({
55
+ name: p.name,
56
+ decision: p.decision,
57
+ include: [...p.include],
58
+ ...(p.exclude !== undefined ? { exclude: [...p.exclude] } : {}),
59
+ ...(p.require !== undefined ? { require: [...p.require] } : {}),
60
+ ...(p.session_duration !== undefined ? { session_duration: p.session_duration } : {}),
61
+ })),
62
+ };
63
+ }
64
+ export const accessApplicationProvider = {
65
+ resourceType: 'AccessApplication',
66
+ schema: accessApplicationSchema,
67
+ async *list(ctx) {
68
+ let iter;
69
+ try {
70
+ iter = ctx.cloudflare.zeroTrust.access.applications.list({ account_id: ctx.accountId });
71
+ }
72
+ catch (raw) {
73
+ throw toProviderError(raw);
74
+ }
75
+ try {
76
+ for await (const app of iter) {
77
+ const a = app;
78
+ if (!a.id)
79
+ continue;
80
+ const label = parseLabel(a.name);
81
+ if (label === null)
82
+ continue;
83
+ yield { nativeId: a.id, label };
84
+ }
85
+ }
86
+ catch (raw) {
87
+ throw toProviderError(raw);
88
+ }
89
+ },
90
+ async read(ctx, nativeId) {
91
+ try {
92
+ const app = (await ctx.cloudflare.zeroTrust.access.applications.get(nativeId, {
93
+ account_id: ctx.accountId,
94
+ }));
95
+ if (!app.name || !app.domain)
96
+ return NotFound;
97
+ // Reading back the policies is best-effort: Cloudflare returns a richer shape
98
+ // than we store, so we narrow back to the wire types we know how to emit.
99
+ // Unknown rule types are dropped from the read result, which means a manual
100
+ // edit in the dashboard may produce a perpetual "drift" — accepted for v0.4.
101
+ const policies = [];
102
+ for (const raw of app.policies ?? []) {
103
+ const p = raw;
104
+ if (!p.name || !p.decision || !p.include)
105
+ continue;
106
+ policies.push({
107
+ name: p.name,
108
+ decision: p.decision,
109
+ include: p.include,
110
+ ...(p.exclude !== undefined
111
+ ? { exclude: p.exclude }
112
+ : {}),
113
+ ...(p.require !== undefined
114
+ ? { require: p.require }
115
+ : {}),
116
+ ...(p.session_duration !== undefined ? { session_duration: p.session_duration } : {}),
117
+ });
118
+ }
119
+ return {
120
+ appName: app.name,
121
+ domain: app.domain,
122
+ ...(app.session_duration !== undefined
123
+ ? { sessionDuration: app.session_duration }
124
+ : {}),
125
+ ...(app.auto_redirect_to_identity !== undefined
126
+ ? { autoRedirectToIdentity: app.auto_redirect_to_identity }
127
+ : {}),
128
+ ...(app.allowed_idps !== undefined ? { allowedIdps: [...app.allowed_idps] } : {}),
129
+ policies,
130
+ };
131
+ }
132
+ catch (raw) {
133
+ const err = toProviderError(raw);
134
+ if (err.code === 'NotFound')
135
+ return NotFound;
136
+ throw err;
137
+ }
138
+ },
139
+ async create(ctx, _label, desired) {
140
+ try {
141
+ const app = (await ctx.cloudflare.zeroTrust.access.applications.create({
142
+ account_id: ctx.accountId,
143
+ ...buildBody(desired),
144
+ }));
145
+ return {
146
+ kind: 'sync',
147
+ nativeId: app.id ?? desired.appName,
148
+ properties: desired,
149
+ };
150
+ }
151
+ catch (raw) {
152
+ throw toProviderError(raw);
153
+ }
154
+ },
155
+ async update(ctx, nativeId, _prior, desired) {
156
+ try {
157
+ const app = (await ctx.cloudflare.zeroTrust.access.applications.update(nativeId, {
158
+ account_id: ctx.accountId,
159
+ ...buildBody(desired),
160
+ }));
161
+ return {
162
+ kind: 'sync',
163
+ nativeId: app.id ?? nativeId,
164
+ properties: desired,
165
+ };
166
+ }
167
+ catch (raw) {
168
+ throw toProviderError(raw);
169
+ }
170
+ },
171
+ async delete(ctx, nativeId) {
172
+ try {
173
+ await ctx.cloudflare.zeroTrust.access.applications.delete(nativeId, {
174
+ account_id: ctx.accountId,
175
+ });
176
+ return { kind: 'sync' };
177
+ }
178
+ catch (raw) {
179
+ throw toProviderError(raw);
180
+ }
181
+ },
182
+ };
183
+ //# sourceMappingURL=access-application.js.map
@@ -13,6 +13,8 @@ import { vectorizeProvider } from "./vectorize.js";
13
13
  import { dnsRecordProvider } from "./dns-record.js";
14
14
  import { workflowProvider } from "./workflow.js";
15
15
  import { logpushJobProvider } from "./logpush-job.js";
16
+ import { workerRouteProvider } from "./worker-route.js";
17
+ import { accessApplicationProvider } from "./access-application.js";
16
18
  export function createDefaultRegistry() {
17
19
  const r = new ProviderRegistry();
18
20
  r.register(workerProvider);
@@ -29,6 +31,8 @@ export function createDefaultRegistry() {
29
31
  r.register(dnsRecordProvider);
30
32
  r.register(workflowProvider);
31
33
  r.register(logpushJobProvider);
34
+ r.register(workerRouteProvider);
35
+ r.register(accessApplicationProvider);
32
36
  return r;
33
37
  }
34
38
  export { ProviderRegistry } from "./registry.js";
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ import type { CloudflareResourceProvider } from './types.ts';
3
+ export interface WorkerRouteProperties {
4
+ readonly zoneId: string;
5
+ readonly pattern: string;
6
+ readonly scriptName: string;
7
+ }
8
+ export declare const workerRouteSchema: z.ZodType<WorkerRouteProperties>;
9
+ export declare const workerRouteProvider: CloudflareResourceProvider<WorkerRouteProperties>;
10
+ //# sourceMappingURL=worker-route.d.ts.map
@@ -0,0 +1,115 @@
1
+ import { z } from 'zod';
2
+ import { NotFound } from "./types.js";
3
+ import { toProviderError } from "./errors.js";
4
+ export const workerRouteSchema = z.object({
5
+ zoneId: z.string(),
6
+ pattern: z.string(),
7
+ scriptName: z.string(),
8
+ });
9
+ const SCRIPT_PREFIX = 'k1c--';
10
+ /**
11
+ * Cloudflare Workers Routes carry no per-route comment field, so ownership is
12
+ * inferred from the bound `script` starting with the k1c naming prefix. This
13
+ * is the same approach used by the CustomDomain provider.
14
+ */
15
+ function isManaged(script) {
16
+ return typeof script === 'string' && script.startsWith(SCRIPT_PREFIX);
17
+ }
18
+ /**
19
+ * Routes are keyed on `pattern` for the purpose of label matching — the SDK
20
+ * surface lets us list by zone, and within a zone the pattern is unique.
21
+ */
22
+ function labelFromPattern(pattern) {
23
+ if (typeof pattern !== 'string' || pattern.length === 0)
24
+ return null;
25
+ return pattern;
26
+ }
27
+ export const workerRouteProvider = {
28
+ resourceType: 'WorkerRoute',
29
+ schema: workerRouteSchema,
30
+ async *list(ctx) {
31
+ if (ctx.zoneId === undefined) {
32
+ // Routes are zone-scoped; without a zone we cannot enumerate. Yield nothing.
33
+ return;
34
+ }
35
+ let iter;
36
+ try {
37
+ iter = ctx.cloudflare.workers.routes.list({ zone_id: ctx.zoneId });
38
+ }
39
+ catch (raw) {
40
+ throw toProviderError(raw);
41
+ }
42
+ try {
43
+ for await (const r of iter) {
44
+ if (!isManaged(r.script))
45
+ continue;
46
+ const label = labelFromPattern(r.pattern);
47
+ if (label === null || !r.id)
48
+ continue;
49
+ yield { nativeId: r.id, label };
50
+ }
51
+ }
52
+ catch (raw) {
53
+ throw toProviderError(raw);
54
+ }
55
+ },
56
+ async read(ctx, nativeId) {
57
+ if (ctx.zoneId === undefined)
58
+ return NotFound;
59
+ try {
60
+ const r = await ctx.cloudflare.workers.routes.get(nativeId, { zone_id: ctx.zoneId });
61
+ if (!r.pattern || !r.script)
62
+ return NotFound;
63
+ return { zoneId: ctx.zoneId, pattern: r.pattern, scriptName: r.script };
64
+ }
65
+ catch (raw) {
66
+ const err = toProviderError(raw);
67
+ if (err.code === 'NotFound')
68
+ return NotFound;
69
+ throw err;
70
+ }
71
+ },
72
+ async create(_ctx, _label, desired) {
73
+ try {
74
+ const r = await _ctx.cloudflare.workers.routes.create({
75
+ zone_id: desired.zoneId,
76
+ pattern: desired.pattern,
77
+ script: desired.scriptName,
78
+ });
79
+ return { kind: 'sync', nativeId: r.id, properties: desired };
80
+ }
81
+ catch (raw) {
82
+ throw toProviderError(raw);
83
+ }
84
+ },
85
+ async update(ctx, nativeId, _prior, desired) {
86
+ try {
87
+ const r = await ctx.cloudflare.workers.routes.update(nativeId, {
88
+ zone_id: desired.zoneId,
89
+ pattern: desired.pattern,
90
+ script: desired.scriptName,
91
+ });
92
+ return { kind: 'sync', nativeId: r.id, properties: desired };
93
+ }
94
+ catch (raw) {
95
+ throw toProviderError(raw);
96
+ }
97
+ },
98
+ async delete(ctx, nativeId) {
99
+ if (ctx.zoneId === undefined) {
100
+ throw {
101
+ code: 'InvalidRequest',
102
+ recoverable: false,
103
+ message: 'WorkerRoute delete requires zoneId in ProviderContext',
104
+ };
105
+ }
106
+ try {
107
+ await ctx.cloudflare.workers.routes.delete(nativeId, { zone_id: ctx.zoneId });
108
+ return { kind: 'sync' };
109
+ }
110
+ catch (raw) {
111
+ throw toProviderError(raw);
112
+ }
113
+ },
114
+ };
115
+ //# sourceMappingURL=worker-route.js.map
@@ -127,9 +127,11 @@ function deletePriority(resourceType) {
127
127
  // Top-level edges — nothing else points at these. Delete first so we do not
128
128
  // serve traffic to a Worker we are about to remove.
129
129
  case 'CustomDomain':
130
+ case 'WorkerRoute':
130
131
  case 'DNSRecord':
131
132
  case 'LogpushJob':
132
133
  case 'Workflow':
134
+ case 'AccessApplication':
133
135
  return 0;
134
136
  case 'Worker':
135
137
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mizchi/k1c",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Experimental kubectl-style apply tool for Cloudflare",
5
5
  "keywords": [
6
6
  "cloudflare",