@tinacms/graphql 3.0.0 → 4.0.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.
@@ -20,6 +20,16 @@ export interface DatabaseArgs {
20
20
  version?: boolean;
21
21
  namespace?: string;
22
22
  levelBatchSize?: number;
23
+ authCollection?: AuthCollectionOptions;
24
+ }
25
+ export interface AuthCollectionOptions {
26
+ /** Values of the uid field of users who can edit the auth collection. */
27
+ admins?: string[];
28
+ /**
29
+ * Allow writes to the auth collection from calls that pass no ctxUser.
30
+ * A null ctxUser means nobody is signed in, and is still refused.
31
+ */
32
+ allowUnauthenticatedWrites?: boolean;
23
33
  }
24
34
  export interface GitProvider {
25
35
  onPut: (key: string, value: string) => Promise<void>;
@@ -47,6 +57,10 @@ export type CreateLocalDatabaseArgs = Omit<DatabaseArgs, 'level'> & {
47
57
  };
48
58
  export declare const createLocalDatabase: (config?: CreateLocalDatabaseArgs) => Database;
49
59
  export declare const createDatabase: (config: CreateDatabase) => Database;
60
+ /**
61
+ * Builds a database for CLI tooling and tests. It trusts requests without a
62
+ * user, so do not use it for a public endpoint. Use `createDatabase` instead.
63
+ */
50
64
  export declare const createDatabaseInternal: (config: DatabaseArgs) => Database;
51
65
  /** Options for {@link Database.query} **/
52
66
  export type QueryOptions = {
@@ -71,6 +85,10 @@ export declare class Database {
71
85
  private readonly onPut;
72
86
  private readonly onDelete;
73
87
  private readonly levelBatchSize;
88
+ readonly authCollection: Readonly<{
89
+ admins: readonly string[];
90
+ allowUnauthenticatedWrites: boolean;
91
+ }>;
74
92
  private tinaSchema;
75
93
  private contentNamespace;
76
94
  private collectionIndexDefinitions;
package/dist/index.js CHANGED
@@ -3058,7 +3058,7 @@ var assertCheckboxGroupIsList = (field) => {
3058
3058
  var package_default = {
3059
3059
  name: "@tinacms/graphql",
3060
3060
  type: "module",
3061
- version: "3.0.0",
3061
+ version: "4.0.0",
3062
3062
  main: "dist/index.js",
3063
3063
  module: "./dist/index.js",
3064
3064
  files: [
@@ -3348,7 +3348,7 @@ import {
3348
3348
  } from "graphql";
3349
3349
 
3350
3350
  // src/resolver/index.ts
3351
- import path3 from "path";
3351
+ import path4 from "path";
3352
3352
  import { isValid } from "date-fns";
3353
3353
  import { JSONPath as JSONPath2 } from "jsonpath-plus";
3354
3354
 
@@ -4683,6 +4683,170 @@ var collectConditionsForField = (fieldName, field, filterNode, pathExpression, c
4683
4683
  }
4684
4684
  };
4685
4685
 
4686
+ // src/resolver/auth-fields.ts
4687
+ import { randomBytes } from "crypto";
4688
+ import path3 from "path";
4689
+ async function getUserDocumentContext(tinaSchema, resolver) {
4690
+ const collection = tinaSchema.getCollections().find((c) => c.isAuthCollection);
4691
+ if (!collection) {
4692
+ throw new Error("Auth collection not found");
4693
+ }
4694
+ const userFields = mapUserFields(collection, ["_rawData"]);
4695
+ if (!userFields.length) {
4696
+ throw new Error(`No user field found in collection ${collection.name}`);
4697
+ }
4698
+ if (userFields.length > 1) {
4699
+ throw new Error(
4700
+ `Multiple user fields found in collection ${collection.name}`
4701
+ );
4702
+ }
4703
+ const userField = userFields[0];
4704
+ const relativePath = "index.json";
4705
+ const realPath = path3.join(collection.path, relativePath);
4706
+ const userDoc = await resolver.getDocument(realPath);
4707
+ const users = get(userDoc, userField.path);
4708
+ if (!users) {
4709
+ throw new Error("No users found");
4710
+ }
4711
+ return { collection, userField, users, userDoc, relativePath };
4712
+ }
4713
+ function findUserInCollection(users, userField, userSub) {
4714
+ const { idFieldName } = userField;
4715
+ if (!idFieldName) {
4716
+ throw new Error("No uid field found on user field");
4717
+ }
4718
+ return users.find((u) => u[idFieldName] === userSub) || null;
4719
+ }
4720
+ function withoutPasswordValue(user, userField) {
4721
+ const { passwordFieldName } = userField;
4722
+ if (!passwordFieldName) {
4723
+ return user;
4724
+ }
4725
+ return {
4726
+ ...user,
4727
+ [passwordFieldName]: {
4728
+ passwordChangeRequired: user[passwordFieldName]?.passwordChangeRequired ?? false
4729
+ }
4730
+ };
4731
+ }
4732
+ var dummyPasswordHash;
4733
+ var warnedUnflaggedAuthenticate = false;
4734
+ var getDummyPasswordHash = () => {
4735
+ dummyPasswordHash ??= generatePasswordHash({
4736
+ password: randomBytes(32).toString("hex")
4737
+ });
4738
+ return dummyPasswordHash;
4739
+ };
4740
+ function assertNewUsersHavePasswords(collection, newBody, existingData) {
4741
+ const userFields = mapUserFields(collection);
4742
+ if (userFields.length !== 1) {
4743
+ return;
4744
+ }
4745
+ const [{ path: usersPath, idFieldName, passwordFieldName }] = userFields;
4746
+ const users = get(newBody, usersPath);
4747
+ if (!Array.isArray(users) || !idFieldName || !passwordFieldName) {
4748
+ return;
4749
+ }
4750
+ const storedUsers = get(existingData, usersPath);
4751
+ const storedIds = new Set(
4752
+ (Array.isArray(storedUsers) ? storedUsers : []).map((u) => u?.[idFieldName])
4753
+ );
4754
+ const withoutPassword = users.filter(
4755
+ (u) => !storedIds.has(u?.[idFieldName]) && !u?.[passwordFieldName]?.["value"]
4756
+ ).map((u) => u?.[idFieldName]);
4757
+ if (withoutPassword.length) {
4758
+ throw new Error(`New users need a password: ${withoutPassword.join(", ")}`);
4759
+ }
4760
+ }
4761
+ async function handleAuthenticate({
4762
+ tinaSchema,
4763
+ resolver,
4764
+ sub,
4765
+ password,
4766
+ isSignIn
4767
+ }) {
4768
+ if (!isSignIn) {
4769
+ if (!warnedUnflaggedAuthenticate) {
4770
+ warnedUnflaggedAuthenticate = true;
4771
+ console.warn(
4772
+ "TinaCMS: authenticate was called without isSignIn, so it returned null. If sign-in is failing, run `tinacms build` with a @tinacms/cli that matches @tinacms/datalayer to regenerate the database client."
4773
+ );
4774
+ }
4775
+ return null;
4776
+ }
4777
+ const { userField, users } = await getUserDocumentContext(
4778
+ tinaSchema,
4779
+ resolver
4780
+ );
4781
+ const user = sub ? findUserInCollection(users, userField, sub) : null;
4782
+ const { passwordFieldName } = userField;
4783
+ const saltedHash = user ? get(user, [passwordFieldName || "", "value"]) : void 0;
4784
+ if (!saltedHash) {
4785
+ await checkPasswordHash({
4786
+ saltedHash: await getDummyPasswordHash(),
4787
+ password: password ?? ""
4788
+ });
4789
+ return null;
4790
+ }
4791
+ const matches = await checkPasswordHash({
4792
+ saltedHash,
4793
+ password
4794
+ });
4795
+ return matches ? withoutPasswordValue(user, userField) : null;
4796
+ }
4797
+ async function handleAuthorize({
4798
+ tinaSchema,
4799
+ resolver,
4800
+ sub,
4801
+ ctxUser
4802
+ }) {
4803
+ const userSub = sub || ctxUser?.sub;
4804
+ const { userField, users } = await getUserDocumentContext(
4805
+ tinaSchema,
4806
+ resolver
4807
+ );
4808
+ const user = findUserInCollection(users, userField, userSub);
4809
+ return user ? withoutPasswordValue(user, userField) : null;
4810
+ }
4811
+ async function handleUpdatePassword({
4812
+ tinaSchema,
4813
+ resolver,
4814
+ password,
4815
+ ctxUser
4816
+ }) {
4817
+ if (!ctxUser?.sub) {
4818
+ throw new Error("Not authorized");
4819
+ }
4820
+ if (!password) {
4821
+ throw new Error("No password provided");
4822
+ }
4823
+ const { userField, users } = await getUserDocumentContext(
4824
+ tinaSchema,
4825
+ resolver
4826
+ );
4827
+ const { idFieldName, passwordFieldName } = userField;
4828
+ if (!passwordFieldName) {
4829
+ throw new Error("No password field found on user field");
4830
+ }
4831
+ if (!users.find((u) => u[idFieldName] === ctxUser.sub)) {
4832
+ throw new Error("Not authorized");
4833
+ }
4834
+ const passwordHash = await generatePasswordHash({ password });
4835
+ await resolver.updateAuthDocumentInternal((rawData) => {
4836
+ const storedUsers = get(rawData, userField.path.slice(1));
4837
+ const user = Array.isArray(storedUsers) ? storedUsers.find((u) => u[idFieldName] === ctxUser.sub) : void 0;
4838
+ if (!user) {
4839
+ throw new Error("Not authorized");
4840
+ }
4841
+ user[passwordFieldName] = {
4842
+ value: passwordHash,
4843
+ passwordChangeRequired: false
4844
+ };
4845
+ return rawData;
4846
+ });
4847
+ return true;
4848
+ }
4849
+
4686
4850
  // src/resolver/media-utils.ts
4687
4851
  var resolveMediaCloudToRelative = (value, config = { useRelativeMedia: true }, schema) => {
4688
4852
  if (config && value) {
@@ -4764,6 +4928,7 @@ var hasTinaMediaConfig = (schema) => {
4764
4928
  };
4765
4929
 
4766
4930
  // src/resolver/index.ts
4931
+ var authCollectionWrites = /* @__PURE__ */ new WeakMap();
4767
4932
  var createResolver = (args) => {
4768
4933
  return new Resolver(args);
4769
4934
  };
@@ -4903,7 +5068,7 @@ var transformDocumentIntoPayload = async (fullPath, rawData, tinaSchema, config,
4903
5068
  base: basename,
4904
5069
  ext: extension,
4905
5070
  name: filename
4906
- } = path3.parse(normalizedPath);
5071
+ } = path4.parse(normalizedPath);
4907
5072
  const relativePath = normalizedPath.replace(collection.path, "").replace(/^\/|\/$/g, "");
4908
5073
  const breadcrumbs = relativePath.replace(extension, "").split("/");
4909
5074
  const data = {
@@ -5000,12 +5165,14 @@ var Resolver = class _Resolver {
5000
5165
  this.database = init.database;
5001
5166
  this.tinaSchema = init.tinaSchema;
5002
5167
  this.isAudit = init.isAudit;
5168
+ this.ctxUser = init.ctxUser;
5003
5169
  }
5004
5170
  init;
5005
5171
  config;
5006
5172
  database;
5007
5173
  tinaSchema;
5008
5174
  isAudit;
5175
+ ctxUser;
5009
5176
  resolveCollection = async (_args, collectionName, hasDocuments) => {
5010
5177
  const collection = this.tinaSchema.getCollection(collectionName);
5011
5178
  const extraFields = {};
@@ -5182,6 +5349,11 @@ var Resolver = class _Resolver {
5182
5349
  collectionName,
5183
5350
  relativePath
5184
5351
  );
5352
+ await this.assertAuthCollectionWrite({
5353
+ collection,
5354
+ realPath,
5355
+ op: "addPending"
5356
+ });
5185
5357
  const alreadyExists = await this.database.documentExists(realPath);
5186
5358
  if (alreadyExists) {
5187
5359
  throw new Error(
@@ -5297,19 +5469,19 @@ var Resolver = class _Resolver {
5297
5469
  if (fullPath.includes("\0")) {
5298
5470
  throw new Error("Invalid path: null bytes are not allowed");
5299
5471
  }
5300
- const normalizedPath = path3.normalize(fullPath.replace(/\\/g, "/"));
5301
- const normalizedCollectionPath = path3.normalize(collection.path);
5302
- const relative = path3.relative(normalizedCollectionPath, normalizedPath);
5472
+ const normalizedPath = path4.normalize(fullPath.replace(/\\/g, "/"));
5473
+ const normalizedCollectionPath = path4.normalize(collection.path);
5474
+ const relative = path4.relative(normalizedCollectionPath, normalizedPath);
5303
5475
  if (relative.startsWith("..")) {
5304
5476
  throw new Error(`Invalid path: path escapes the collection directory`);
5305
5477
  }
5306
- if (path3.isAbsolute(relative)) {
5478
+ if (path4.isAbsolute(relative)) {
5307
5479
  throw new Error(`Invalid path: absolute paths are not allowed`);
5308
5480
  }
5309
5481
  if (relativePath) {
5310
5482
  _Resolver.validateRelativePath(relativePath);
5311
5483
  const collectionFormat = collection.format || "md";
5312
- const fileExtension = path3.extname(relativePath).toLowerCase().slice(1);
5484
+ const fileExtension = path4.extname(relativePath).toLowerCase().slice(1);
5313
5485
  if (fileExtension !== collectionFormat) {
5314
5486
  throw new Error(
5315
5487
  `Invalid file extension: expected '.${collectionFormat}' but got '.${fileExtension}'`
@@ -5317,6 +5489,81 @@ var Resolver = class _Resolver {
5317
5489
  }
5318
5490
  }
5319
5491
  };
5492
+ assertAuthCollectionWrite = async ({
5493
+ collection,
5494
+ realPath,
5495
+ op
5496
+ }) => {
5497
+ const authCollection = this.tinaSchema.getCollections().find((c) => c.isAuthCollection);
5498
+ if (!authCollection) {
5499
+ return;
5500
+ }
5501
+ const normalizedPath = normalizePath2(realPath);
5502
+ if (collection.name !== authCollection.name && !this.isStoredInCollection(normalizedPath, authCollection)) {
5503
+ return;
5504
+ }
5505
+ const { admins, allowUnauthenticatedWrites } = this.database.authCollection ?? { admins: [], allowUnauthenticatedWrites: true };
5506
+ if (this.ctxUser === void 0 && allowUnauthenticatedWrites) {
5507
+ return;
5508
+ }
5509
+ const sub = this.ctxUser?.sub;
5510
+ const userStorePath = normalizePath2(
5511
+ path4.join(authCollection.path, "index.json")
5512
+ );
5513
+ if (op === "update" && normalizedPath === userStorePath && typeof sub === "string" && sub !== "" && admins.includes(sub) && await this.isStoredUser(sub)) {
5514
+ return;
5515
+ }
5516
+ throw new Error("Not authorized");
5517
+ };
5518
+ assertReferenceUpdates = async (realPath, collection) => {
5519
+ if (!this.tinaSchema.getCollections().some((c) => c.isAuthCollection)) {
5520
+ return;
5521
+ }
5522
+ const collRefs = await this.findReferences(realPath, collection);
5523
+ for (const docsWithRefs of Object.values(collRefs)) {
5524
+ for (const pathToDocWithRef of Object.keys(docsWithRefs)) {
5525
+ await this.assertAuthCollectionWrite({
5526
+ collection: this.tinaSchema.getCollectionByFullPath(pathToDocWithRef),
5527
+ realPath: pathToDocWithRef,
5528
+ op: "update"
5529
+ });
5530
+ }
5531
+ }
5532
+ };
5533
+ isAuthCollectionPath = (realPath) => {
5534
+ const authCollection = this.tinaSchema.getCollections().find((c) => c.isAuthCollection);
5535
+ return !!authCollection && this.isStoredInCollection(normalizePath2(realPath), authCollection);
5536
+ };
5537
+ withAuthCollectionLock = (realPath, write) => {
5538
+ if (!this.isAuthCollectionPath(realPath)) {
5539
+ return write();
5540
+ }
5541
+ const previous = authCollectionWrites.get(this.database);
5542
+ const current = previous ? previous.then(write, write) : write();
5543
+ authCollectionWrites.set(
5544
+ this.database,
5545
+ current.catch(() => void 0)
5546
+ );
5547
+ return current;
5548
+ };
5549
+ isStoredInCollection = (realPath, collection) => {
5550
+ try {
5551
+ return this.tinaSchema.getCollectionByFullPath(realPath)?.name === collection.name;
5552
+ } catch {
5553
+ return false;
5554
+ }
5555
+ };
5556
+ isStoredUser = async (sub) => {
5557
+ try {
5558
+ const { userField, users } = await getUserDocumentContext(
5559
+ this.tinaSchema,
5560
+ this
5561
+ );
5562
+ return Array.isArray(users) && !!findUserInCollection(users, userField, sub);
5563
+ } catch {
5564
+ return false;
5565
+ }
5566
+ };
5320
5567
  /**
5321
5568
  * Helper method to get collection and construct validated path.
5322
5569
  * This encapsulates the common pattern of getting a collection, joining paths,
@@ -5337,7 +5584,7 @@ var Resolver = class _Resolver {
5337
5584
  if (options?.extraSegments) {
5338
5585
  pathSegments.push(...options.extraSegments.map(_Resolver.sanitizePath));
5339
5586
  }
5340
- const realPath = normalizePath2(path3.join(...pathSegments));
5587
+ const realPath = normalizePath2(path4.join(...pathSegments));
5341
5588
  const shouldValidateExtension = options?.validateExtension !== false;
5342
5589
  this.validatePath(
5343
5590
  realPath,
@@ -5371,12 +5618,17 @@ var Resolver = class _Resolver {
5371
5618
  }) => {
5372
5619
  const collection = this.getCollectionWithName(collectionName);
5373
5620
  _Resolver.validateRelativePath(relativePath);
5374
- const realPath = path3.join(
5621
+ const realPath = path4.join(
5375
5622
  collection.path,
5376
5623
  relativePath,
5377
5624
  `.gitkeep.${collection.format || "md"}`
5378
5625
  );
5379
5626
  this.validatePath(realPath, collection);
5627
+ await this.assertAuthCollectionWrite({
5628
+ collection,
5629
+ realPath,
5630
+ op: "createFolder"
5631
+ });
5380
5632
  const alreadyExists = await this.database.documentExists(realPath);
5381
5633
  if (alreadyExists) {
5382
5634
  throw new Error(
@@ -5399,6 +5651,11 @@ var Resolver = class _Resolver {
5399
5651
  collectionName,
5400
5652
  relativePath
5401
5653
  );
5654
+ await this.assertAuthCollectionWrite({
5655
+ collection,
5656
+ realPath,
5657
+ op: "create"
5658
+ });
5402
5659
  const alreadyExists = await this.database.documentExists(realPath);
5403
5660
  if (alreadyExists) {
5404
5661
  throw new Error(
@@ -5409,7 +5666,17 @@ var Resolver = class _Resolver {
5409
5666
  await this.database.put(realPath, params, collection.name);
5410
5667
  return this.getDocument(realPath);
5411
5668
  };
5412
- resolveUpdateDocument = async ({
5669
+ resolveUpdateDocument = async (args) => {
5670
+ const { realPath } = this.getValidatedPath(
5671
+ args.collectionName,
5672
+ args.relativePath
5673
+ );
5674
+ return this.withAuthCollectionLock(
5675
+ realPath,
5676
+ () => this.updateDocument(args)
5677
+ );
5678
+ };
5679
+ updateDocument = async ({
5413
5680
  collectionName,
5414
5681
  relativePath,
5415
5682
  newRelativePath,
@@ -5419,6 +5686,13 @@ var Resolver = class _Resolver {
5419
5686
  collectionName,
5420
5687
  relativePath
5421
5688
  );
5689
+ if (!newRelativePath) {
5690
+ await this.assertAuthCollectionWrite({
5691
+ collection,
5692
+ realPath,
5693
+ op: "update"
5694
+ });
5695
+ }
5422
5696
  const alreadyExists = await this.database.documentExists(realPath);
5423
5697
  if (!alreadyExists) {
5424
5698
  throw new Error(`Unable to update document, ${realPath} does not exist`);
@@ -5438,38 +5712,53 @@ var Resolver = class _Resolver {
5438
5712
  `Unable to rename document, ${newRealPath} ${ERR_ALREADY_EXISTS}`
5439
5713
  );
5440
5714
  }
5715
+ await this.assertAuthCollectionWrite({
5716
+ collection,
5717
+ realPath,
5718
+ op: "rename"
5719
+ });
5720
+ await this.assertAuthCollectionWrite({
5721
+ collection,
5722
+ realPath: newRealPath,
5723
+ op: "rename"
5724
+ });
5725
+ await this.assertReferenceUpdates(realPath, collection);
5441
5726
  await this.database.put(newRealPath, doc._rawData, collection.name);
5442
5727
  await this.deleteDocument(realPath);
5443
5728
  const collRefs = await this.findReferences(realPath, collection);
5729
+ const holdsLock = this.isAuthCollectionPath(realPath);
5444
5730
  for (const [_collection, docsWithRefs] of Object.entries(collRefs)) {
5445
5731
  for (const [pathToDocWithRef, referencePaths] of Object.entries(
5446
5732
  docsWithRefs
5447
5733
  )) {
5448
- let docWithRef = await this.getRaw(pathToDocWithRef);
5449
- let hasUpdate = false;
5450
- for (const path9 of referencePaths) {
5451
- const { object: object2, updated } = updateObjectWithJsonPath(
5452
- docWithRef,
5453
- path9,
5454
- realPath,
5455
- newRealPath
5456
- );
5457
- docWithRef = object2;
5458
- hasUpdate = updated || hasUpdate;
5459
- }
5460
- if (hasUpdate) {
5461
- const collectionWithRef = this.tinaSchema.getCollectionByFullPath(pathToDocWithRef);
5462
- if (!collectionWithRef) {
5463
- throw new Error(
5464
- `Unable to find collection for ${pathToDocWithRef}`
5734
+ const rewriteReferences = async () => {
5735
+ let docWithRef = await this.getRaw(pathToDocWithRef);
5736
+ let hasUpdate = false;
5737
+ for (const path9 of referencePaths) {
5738
+ const { object: object2, updated } = updateObjectWithJsonPath(
5739
+ docWithRef,
5740
+ path9,
5741
+ realPath,
5742
+ newRealPath
5465
5743
  );
5744
+ docWithRef = object2;
5745
+ hasUpdate = updated || hasUpdate;
5466
5746
  }
5467
- await this.database.put(
5468
- pathToDocWithRef,
5469
- docWithRef,
5470
- collectionWithRef.name
5471
- );
5472
- }
5747
+ if (hasUpdate) {
5748
+ const collectionWithRef = this.tinaSchema.getCollectionByFullPath(pathToDocWithRef);
5749
+ if (!collectionWithRef) {
5750
+ throw new Error(
5751
+ `Unable to find collection for ${pathToDocWithRef}`
5752
+ );
5753
+ }
5754
+ await this.database.put(
5755
+ pathToDocWithRef,
5756
+ docWithRef,
5757
+ collectionWithRef.name
5758
+ );
5759
+ }
5760
+ };
5761
+ await (holdsLock ? rewriteReferences() : this.withAuthCollectionLock(pathToDocWithRef, rewriteReferences));
5473
5762
  }
5474
5763
  }
5475
5764
  return this.getDocument(newRealPath);
@@ -5477,6 +5766,9 @@ var Resolver = class _Resolver {
5477
5766
  if (!newBody) {
5478
5767
  throw new Error("Body not provided for updated document.");
5479
5768
  }
5769
+ if (collection.isAuthCollection) {
5770
+ assertNewUsersHavePasswords(collection, newBody, doc?._rawData);
5771
+ }
5480
5772
  const params = await this.buildObjectMutations(
5481
5773
  newBody,
5482
5774
  collection,
@@ -5493,6 +5785,21 @@ var Resolver = class _Resolver {
5493
5785
  );
5494
5786
  return this.getDocument(realPath);
5495
5787
  };
5788
+ /**
5789
+ * Rewrites the auth collection's user store without the admin check, so the
5790
+ * caller must check the request user.
5791
+ */
5792
+ updateAuthDocumentInternal = async (update) => {
5793
+ const collection = this.tinaSchema.getCollections().find((c) => c.isAuthCollection);
5794
+ if (!collection) {
5795
+ throw new Error("Auth collection not found");
5796
+ }
5797
+ const { realPath } = this.getValidatedPath(collection.name, "index.json");
5798
+ await this.withAuthCollectionLock(realPath, async () => {
5799
+ const rawData = await this.getRaw(realPath);
5800
+ await this.database.put(realPath, update(rawData), collection.name);
5801
+ });
5802
+ };
5496
5803
  resolveDeleteDocument = async ({
5497
5804
  collectionName,
5498
5805
  relativePath
@@ -5505,39 +5812,50 @@ var Resolver = class _Resolver {
5505
5812
  if (!alreadyExists) {
5506
5813
  throw new Error(`Unable to delete document, ${realPath} does not exist`);
5507
5814
  }
5815
+ await this.assertAuthCollectionWrite({
5816
+ collection,
5817
+ realPath,
5818
+ op: "delete"
5819
+ });
5820
+ const hasReferences = await this.hasReferences(realPath, collection);
5821
+ if (hasReferences) {
5822
+ await this.assertReferenceUpdates(realPath, collection);
5823
+ }
5508
5824
  const doc = await this.getDocument(realPath);
5509
5825
  await this.deleteDocument(realPath);
5510
- if (await this.hasReferences(realPath, collection)) {
5826
+ if (hasReferences) {
5511
5827
  const collRefs = await this.findReferences(realPath, collection);
5512
5828
  for (const [_collection, docsWithRefs] of Object.entries(collRefs)) {
5513
5829
  for (const [pathToDocWithRef, referencePaths] of Object.entries(
5514
5830
  docsWithRefs
5515
5831
  )) {
5516
- let refDoc = await this.getRaw(pathToDocWithRef);
5517
- let hasUpdate = false;
5518
- for (const path9 of referencePaths) {
5519
- const { object: object2, updated } = updateObjectWithJsonPath(
5520
- refDoc,
5521
- path9,
5522
- realPath,
5523
- null
5524
- );
5525
- refDoc = object2;
5526
- hasUpdate = updated || hasUpdate;
5527
- }
5528
- if (hasUpdate) {
5529
- const collectionWithRef = this.tinaSchema.getCollectionByFullPath(pathToDocWithRef);
5530
- if (!collectionWithRef) {
5531
- throw new Error(
5532
- `Unable to find collection for ${pathToDocWithRef}`
5832
+ await this.withAuthCollectionLock(pathToDocWithRef, async () => {
5833
+ let refDoc = await this.getRaw(pathToDocWithRef);
5834
+ let hasUpdate = false;
5835
+ for (const path9 of referencePaths) {
5836
+ const { object: object2, updated } = updateObjectWithJsonPath(
5837
+ refDoc,
5838
+ path9,
5839
+ realPath,
5840
+ null
5533
5841
  );
5842
+ refDoc = object2;
5843
+ hasUpdate = updated || hasUpdate;
5534
5844
  }
5535
- await this.database.put(
5536
- pathToDocWithRef,
5537
- refDoc,
5538
- collectionWithRef.name
5539
- );
5540
- }
5845
+ if (hasUpdate) {
5846
+ const collectionWithRef = this.tinaSchema.getCollectionByFullPath(pathToDocWithRef);
5847
+ if (!collectionWithRef) {
5848
+ throw new Error(
5849
+ `Unable to find collection for ${pathToDocWithRef}`
5850
+ );
5851
+ }
5852
+ await this.database.put(
5853
+ pathToDocWithRef,
5854
+ refDoc,
5855
+ collectionWithRef.name
5856
+ );
5857
+ }
5858
+ });
5541
5859
  }
5542
5860
  }
5543
5861
  }
@@ -5573,10 +5891,15 @@ var Resolver = class _Resolver {
5573
5891
  if (isAddPendingDocument === true) {
5574
5892
  return this.resolveAddPendingDocument({
5575
5893
  collectionName: collection.name,
5576
- relativePath: path3.relative(collection.path, realPath),
5894
+ relativePath: path4.relative(collection.path, realPath),
5577
5895
  templateName: args.template
5578
5896
  });
5579
5897
  }
5898
+ await this.assertAuthCollectionWrite({
5899
+ collection,
5900
+ realPath,
5901
+ op: "create"
5902
+ });
5580
5903
  const params = await this.buildObjectMutations(
5581
5904
  // @ts-ignore
5582
5905
  args.params[collection.name],
@@ -5595,6 +5918,11 @@ var Resolver = class _Resolver {
5595
5918
  isAddPendingDocument,
5596
5919
  isCollectionSpecific
5597
5920
  }) => {
5921
+ await this.assertAuthCollectionWrite({
5922
+ collection,
5923
+ realPath,
5924
+ op: "update"
5925
+ });
5598
5926
  const doc = await this.getDocument(realPath);
5599
5927
  const oldDoc = this.resolveLegacyValues(doc?._rawData || {}, collection);
5600
5928
  if (isAddPendingDocument === true) {
@@ -6101,130 +6429,6 @@ var resolveDateInput = (value) => {
6101
6429
  return date;
6102
6430
  };
6103
6431
 
6104
- // src/resolver/auth-fields.ts
6105
- import path4 from "path";
6106
- import { set } from "es-toolkit/compat";
6107
- async function getUserDocumentContext(tinaSchema, resolver) {
6108
- const collection = tinaSchema.getCollections().find((c) => c.isAuthCollection);
6109
- if (!collection) {
6110
- throw new Error("Auth collection not found");
6111
- }
6112
- const userFields = mapUserFields(collection, ["_rawData"]);
6113
- if (!userFields.length) {
6114
- throw new Error(`No user field found in collection ${collection.name}`);
6115
- }
6116
- if (userFields.length > 1) {
6117
- throw new Error(
6118
- `Multiple user fields found in collection ${collection.name}`
6119
- );
6120
- }
6121
- const userField = userFields[0];
6122
- const relativePath = "index.json";
6123
- const realPath = path4.join(collection.path, relativePath);
6124
- const userDoc = await resolver.getDocument(realPath);
6125
- const users = get(userDoc, userField.path);
6126
- if (!users) {
6127
- throw new Error("No users found");
6128
- }
6129
- return { collection, userField, users, userDoc, relativePath };
6130
- }
6131
- function findUserInCollection(users, userField, userSub) {
6132
- const { idFieldName } = userField;
6133
- if (!idFieldName) {
6134
- throw new Error("No uid field found on user field");
6135
- }
6136
- return users.find((u) => u[idFieldName] === userSub) || null;
6137
- }
6138
- async function handleAuthenticate({
6139
- tinaSchema,
6140
- resolver,
6141
- sub,
6142
- password,
6143
- ctxUser
6144
- }) {
6145
- const userSub = sub || ctxUser?.sub;
6146
- const { userField, users } = await getUserDocumentContext(
6147
- tinaSchema,
6148
- resolver
6149
- );
6150
- const user = findUserInCollection(users, userField, userSub);
6151
- if (!user) {
6152
- return null;
6153
- }
6154
- const { passwordFieldName } = userField;
6155
- const saltedHash = get(user, [passwordFieldName || "", "value"]);
6156
- if (!saltedHash) {
6157
- throw new Error("No password field found on user field");
6158
- }
6159
- const matches = await checkPasswordHash({
6160
- saltedHash,
6161
- password
6162
- });
6163
- return matches ? user : null;
6164
- }
6165
- async function handleAuthorize({
6166
- tinaSchema,
6167
- resolver,
6168
- sub,
6169
- ctxUser
6170
- }) {
6171
- const userSub = sub || ctxUser?.sub;
6172
- const { userField, users } = await getUserDocumentContext(
6173
- tinaSchema,
6174
- resolver
6175
- );
6176
- const user = findUserInCollection(users, userField, userSub);
6177
- return user ? user : null;
6178
- }
6179
- async function handleUpdatePassword({
6180
- tinaSchema,
6181
- resolver,
6182
- password,
6183
- ctxUser
6184
- }) {
6185
- if (!ctxUser?.sub) {
6186
- throw new Error("Not authorized");
6187
- }
6188
- if (!password) {
6189
- throw new Error("No password provided");
6190
- }
6191
- const { collection, userField, users, relativePath } = await getUserDocumentContext(tinaSchema, resolver);
6192
- const { idFieldName, passwordFieldName } = userField;
6193
- const user = users.find((u) => u[idFieldName] === ctxUser.sub);
6194
- if (!user) {
6195
- throw new Error("Not authorized");
6196
- }
6197
- user[passwordFieldName] = {
6198
- value: password,
6199
- passwordChangeRequired: false
6200
- };
6201
- const newBody = {};
6202
- set(
6203
- newBody,
6204
- userField.path.slice(1),
6205
- // remove _rawData from users path
6206
- users.map((u) => {
6207
- if (user[idFieldName] === u[idFieldName]) {
6208
- return user;
6209
- }
6210
- return {
6211
- // don't overwrite other users' passwords
6212
- ...u,
6213
- [passwordFieldName]: {
6214
- ...u[passwordFieldName],
6215
- value: ""
6216
- }
6217
- };
6218
- })
6219
- );
6220
- await resolver.resolveUpdateDocument({
6221
- collectionName: collection.name,
6222
- relativePath,
6223
- newBody
6224
- });
6225
- return true;
6226
- }
6227
-
6228
6432
  // src/error.ts
6229
6433
  import { GraphQLError as GraphQLError3 } from "graphql";
6230
6434
  var NotFoundError = class extends GraphQLError3 {
@@ -6243,7 +6447,8 @@ var resolve = async ({
6243
6447
  silenceErrors,
6244
6448
  verbose,
6245
6449
  isAudit,
6246
- ctxUser
6450
+ ctxUser,
6451
+ isSignIn
6247
6452
  }) => {
6248
6453
  try {
6249
6454
  const verboseValue = verbose ?? true;
@@ -6262,7 +6467,8 @@ var resolve = async ({
6262
6467
  config,
6263
6468
  database,
6264
6469
  tinaSchema,
6265
- isAudit: isAudit || false
6470
+ isAudit: isAudit || false,
6471
+ ctxUser
6266
6472
  });
6267
6473
  const resolverPromises = [];
6268
6474
  const res = await graphql({
@@ -6306,7 +6512,7 @@ var resolve = async ({
6306
6512
  sub: authArgs.sub,
6307
6513
  password: authArgs.password,
6308
6514
  info,
6309
- ctxUser
6515
+ isSignIn
6310
6516
  });
6311
6517
  }
6312
6518
  if (info.fieldName === "authorize") {
@@ -6672,7 +6878,11 @@ import path5 from "node:path";
6672
6878
  import { GraphQLError as GraphQLError5 } from "graphql";
6673
6879
  import micromatch2 from "micromatch";
6674
6880
  import sha2 from "js-sha1";
6675
- import { set as set2 } from "es-toolkit/compat";
6881
+ import { set } from "es-toolkit/compat";
6882
+ var withUnauthenticatedWrites = (options) => ({
6883
+ ...options,
6884
+ allowUnauthenticatedWrites: options?.allowUnauthenticatedWrites ?? true
6885
+ });
6676
6886
  var createLocalDatabase = (config) => {
6677
6887
  const level = new TinaLevelClient(config?.port);
6678
6888
  level.openConnection();
@@ -6680,7 +6890,8 @@ var createLocalDatabase = (config) => {
6680
6890
  return new Database({
6681
6891
  bridge: fsBridge,
6682
6892
  ...config || {},
6683
- level
6893
+ level,
6894
+ authCollection: withUnauthenticatedWrites(config?.authCollection)
6684
6895
  });
6685
6896
  };
6686
6897
  var createDatabase = (config) => {
@@ -6721,7 +6932,8 @@ var createDatabaseInternal = (config) => {
6721
6932
  return new Database({
6722
6933
  ...config,
6723
6934
  bridge: config.bridge,
6724
- level: config.level
6935
+ level: config.level,
6936
+ authCollection: withUnauthenticatedWrites(config.authCollection)
6725
6937
  });
6726
6938
  };
6727
6939
  var SYSTEM_FILES = ["_schema", "_graphql", "_lookup"];
@@ -6740,6 +6952,10 @@ var Database = class {
6740
6952
  this.onDelete = config.onDelete || defaultOnDelete;
6741
6953
  this.levelBatchSize = config.levelBatchSize ?? DEFAULT_LEVEL_BATCH_SIZE;
6742
6954
  this.contentNamespace = config.namespace;
6955
+ this.authCollection = Object.freeze({
6956
+ admins: Object.freeze([...config.authCollection?.admins ?? []]),
6957
+ allowUnauthenticatedWrites: config.authCollection?.allowUnauthenticatedWrites === true
6958
+ });
6743
6959
  }
6744
6960
  config;
6745
6961
  bridge;
@@ -6751,6 +6967,7 @@ var Database = class {
6751
6967
  onPut;
6752
6968
  onDelete;
6753
6969
  levelBatchSize;
6970
+ authCollection;
6754
6971
  tinaSchema;
6755
6972
  contentNamespace;
6756
6973
  collectionIndexDefinitions;
@@ -7347,7 +7564,7 @@ var Database = class {
7347
7564
  }
7348
7565
  if (fields) {
7349
7566
  for (const field of fields) {
7350
- if (field.indexed !== void 0 && field.indexed === false || field.type === "object") {
7567
+ if (field.indexed !== void 0 && field.indexed === false || field.type === "password" || field.type === "object") {
7351
7568
  continue;
7352
7569
  }
7353
7570
  indexDefinitions[field.name] = {
@@ -7812,7 +8029,7 @@ var hashPasswordVisitor = async (node, path9) => {
7812
8029
  const passwordValuePath = [...path9, "value"];
7813
8030
  const plaintextPassword = get(node, passwordValuePath);
7814
8031
  if (plaintextPassword) {
7815
- set2(
8032
+ set(
7816
8033
  node,
7817
8034
  passwordValuePath,
7818
8035
  await generatePasswordHash({ password: plaintextPassword })
package/dist/resolve.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { GraphQLConfig } from './types';
2
2
  import type { Database } from './database';
3
- export declare const resolve: ({ config, query, variables, database, silenceErrors, verbose, isAudit, ctxUser, }: {
3
+ import { type CtxUser } from './resolver';
4
+ export declare const resolve: ({ config, query, variables, database, silenceErrors, verbose, isAudit, ctxUser, isSignIn, }: {
4
5
  config?: GraphQLConfig;
5
6
  query: string;
6
7
  variables: object;
@@ -8,9 +9,9 @@ export declare const resolve: ({ config, query, variables, database, silenceErro
8
9
  silenceErrors?: boolean;
9
10
  verbose?: boolean;
10
11
  isAudit?: boolean;
11
- ctxUser?: {
12
- sub: string;
13
- };
12
+ ctxUser?: CtxUser;
13
+ /** Set only by the generated database client's `authenticate()`. */
14
+ isSignIn?: boolean;
14
15
  }) => Promise<import("graphql").ExecutionResult<{
15
16
  [key: string]: any;
16
17
  }, {
@@ -1,15 +1,56 @@
1
- import type { TinaSchema } from '@tinacms/schema-tools';
1
+ import type { Collection, TinaSchema } from '@tinacms/schema-tools';
2
2
  import type { GraphQLResolveInfo } from 'graphql';
3
3
  import type { Resolver } from './index';
4
- export declare function handleAuthenticate({ tinaSchema, resolver, sub, password, ctxUser, }: {
4
+ export declare function getUserDocumentContext(tinaSchema: TinaSchema, resolver: Resolver): Promise<{
5
+ collection: Collection<true>;
6
+ userField: {
7
+ path: string[];
8
+ collectable: import("@tinacms/schema-tools").Collectable;
9
+ idFieldName?: string;
10
+ passwordFieldName?: string;
11
+ };
12
+ users: any;
13
+ userDoc: {
14
+ _sys: {
15
+ title: any;
16
+ basename: string;
17
+ filename: string;
18
+ extension: string;
19
+ hasReferences: boolean;
20
+ path: string;
21
+ relativePath: string;
22
+ breadcrumbs: string[];
23
+ collection: Collection<true>;
24
+ template: string | number;
25
+ };
26
+ _values: {
27
+ _collection: any;
28
+ _template: any;
29
+ };
30
+ _rawData: {
31
+ _collection: any;
32
+ _template: any;
33
+ };
34
+ _collection: any;
35
+ _template: any;
36
+ __typename: string;
37
+ id: string;
38
+ };
39
+ relativePath: string;
40
+ }>;
41
+ export declare function findUserInCollection(users: any[], userField: any, userSub: string): any;
42
+ /**
43
+ * A stored hash only carries over to a user with the same uid, so a new or
44
+ * renamed user saved without a password could never sign in.
45
+ */
46
+ export declare function assertNewUsersHavePasswords(collection: Collection<true>, newBody: Record<string, unknown>, existingData?: Record<string, unknown>): void;
47
+ export declare function handleAuthenticate({ tinaSchema, resolver, sub, password, isSignIn, }: {
5
48
  tinaSchema: TinaSchema;
6
49
  resolver: Resolver;
7
50
  sub?: string;
8
51
  password: string;
9
52
  info: GraphQLResolveInfo;
10
- ctxUser?: {
11
- sub?: string;
12
- };
53
+ isSignIn?: boolean;
13
54
  }): Promise<any>;
14
55
  export declare function handleAuthorize({ tinaSchema, resolver, sub, ctxUser, }: {
15
56
  tinaSchema: TinaSchema;
@@ -18,7 +59,7 @@ export declare function handleAuthorize({ tinaSchema, resolver, sub, ctxUser, }:
18
59
  info: GraphQLResolveInfo;
19
60
  ctxUser?: {
20
61
  sub?: string;
21
- };
62
+ } | null;
22
63
  }): Promise<any>;
23
64
  export declare function handleUpdatePassword({ tinaSchema, resolver, password, ctxUser, }: {
24
65
  tinaSchema: TinaSchema;
@@ -27,5 +68,5 @@ export declare function handleUpdatePassword({ tinaSchema, resolver, password, c
27
68
  info: GraphQLResolveInfo;
28
69
  ctxUser?: {
29
70
  sub?: string;
30
- };
71
+ } | null;
31
72
  }): Promise<boolean>;
@@ -4,11 +4,26 @@
4
4
  import { Database } from '../database';
5
5
  import type { Collectable, Collection, Template, TinaField, TinaSchema } from '@tinacms/schema-tools';
6
6
  import type { GraphQLConfig } from '../types';
7
+ export type AuthCollectionWriteOp = 'create' | 'update' | 'rename' | 'delete' | 'addPending' | 'createFolder';
8
+ /**
9
+ * The signed-in user behind a request. Leave it undefined only when the caller
10
+ * vouches for the call itself, and pass null when nobody is signed in.
11
+ */
12
+ export type CtxUser = {
13
+ sub?: string;
14
+ } | null | undefined;
15
+ type UpdateDocumentArgs = {
16
+ collectionName: string;
17
+ relativePath: string;
18
+ newRelativePath?: string;
19
+ newBody?: Record<string, unknown>;
20
+ };
7
21
  interface ResolverConfig {
8
22
  config?: GraphQLConfig;
9
23
  database: Database;
10
24
  tinaSchema: TinaSchema;
11
25
  isAudit: boolean;
26
+ ctxUser?: CtxUser;
12
27
  }
13
28
  export declare const createResolver: (args: ResolverConfig) => Resolver;
14
29
  export declare const resolveFieldData: ({ namespace, ...field }: TinaField<true>, rawData: unknown, accumulator: {
@@ -66,6 +81,7 @@ export declare class Resolver {
66
81
  database: Database;
67
82
  tinaSchema: TinaSchema;
68
83
  isAudit: boolean;
84
+ ctxUser: CtxUser;
69
85
  constructor(init: ResolverConfig);
70
86
  resolveCollection: (_args: any, collectionName: string, hasDocuments?: boolean) => Promise<{
71
87
  fields: TinaField<true>[];
@@ -248,6 +264,12 @@ export declare class Resolver {
248
264
  */
249
265
  private static validateRelativePath;
250
266
  private validatePath;
267
+ private assertAuthCollectionWrite;
268
+ private assertReferenceUpdates;
269
+ private isAuthCollectionPath;
270
+ private withAuthCollectionLock;
271
+ private isStoredInCollection;
272
+ private isStoredUser;
251
273
  /**
252
274
  * Helper method to get collection and construct validated path.
253
275
  * This encapsulates the common pattern of getting a collection, joining paths,
@@ -349,12 +371,7 @@ export declare class Resolver {
349
371
  __typename: string;
350
372
  id: string;
351
373
  }>;
352
- resolveUpdateDocument: ({ collectionName, relativePath, newRelativePath, newBody, }: {
353
- collectionName: string;
354
- relativePath: string;
355
- newRelativePath?: string;
356
- newBody?: Record<string, unknown>;
357
- }) => Promise<{
374
+ resolveUpdateDocument: (args: UpdateDocumentArgs) => Promise<{
358
375
  _sys: {
359
376
  title: any;
360
377
  basename: string;
@@ -380,6 +397,12 @@ export declare class Resolver {
380
397
  __typename: string;
381
398
  id: string;
382
399
  }>;
400
+ private updateDocument;
401
+ /**
402
+ * Rewrites the auth collection's user store without the admin check, so the
403
+ * caller must check the request user.
404
+ */
405
+ updateAuthDocumentInternal: (update: (rawData: Record<string, any>) => Record<string, any>) => Promise<void>;
383
406
  resolveDeleteDocument: ({ collectionName, relativePath, }: {
384
407
  collectionName: string;
385
408
  relativePath: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tinacms/graphql",
3
3
  "type": "module",
4
- "version": "3.0.0",
4
+ "version": "4.0.0",
5
5
  "main": "dist/index.js",
6
6
  "module": "./dist/index.js",
7
7
  "files": [
@@ -43,8 +43,8 @@
43
43
  "normalize-path": "^3.0.0",
44
44
  "readable-stream": "^4.7.0",
45
45
  "yup": "^1.6.1",
46
- "@tinacms/mdx": "^2.2.3",
47
- "@tinacms/schema-tools": "^2.10.0"
46
+ "@tinacms/schema-tools": "^2.10.1",
47
+ "@tinacms/mdx": "^2.3.0"
48
48
  },
49
49
  "publishConfig": {
50
50
  "registry": "https://registry.npmjs.org"
@@ -72,7 +72,7 @@
72
72
  "vite": "^4.5.9",
73
73
  "vitest": "^0.32.4",
74
74
  "zod": "^3.24.2",
75
- "@tinacms/schema-tools": "2.10.0",
75
+ "@tinacms/schema-tools": "2.10.1",
76
76
  "@tinacms/scripts": "1.6.4"
77
77
  },
78
78
  "scripts": {